chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { findClipForAsset, isPointerClick, DRAG_THRESHOLD_PX } from "./assetClickBehavior";
import type { TimelineElement } from "../player/store/playerStore";
// Minimal TimelineElement factory — only the fields the function inspects.
function makeEl(overrides: Partial<TimelineElement> & { id: string }): TimelineElement {
return {
tag: "div",
start: 0,
duration: 5,
track: 0,
...overrides,
};
}
describe("findClipForAsset", () => {
it("returns null when elements array is empty", () => {
expect(findClipForAsset([], "assets/foo.mp4")).toBeNull();
});
it("returns null when no element matches", () => {
const el = makeEl({ id: "el1", src: "assets/other.mp4" });
expect(findClipForAsset([el], "assets/foo.mp4")).toBeNull();
});
it("matches a bare relative src against the project-relative asset path", () => {
const el = makeEl({ id: "el1", src: "assets/clip.mp4", start: 2 });
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
});
it("matches a src with a ./ prefix", () => {
const el = makeEl({ id: "el1", src: "./assets/logo.png" });
expect(findClipForAsset([el], "assets/logo.png")).toBe(el);
});
it("matches a server-relative /api/projects/…/preview/ src", () => {
const el = makeEl({ id: "el1", src: "/api/projects/demo/preview/assets/bgm.mp3" });
expect(findClipForAsset([el], "assets/bgm.mp3")).toBe(el);
});
it("matches a fully-absolute URL (as produced by the core runtime)", () => {
const el = makeEl({
id: "el1",
src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4",
});
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
});
it("decodes percent-encoded filenames when matching", () => {
const el = makeEl({
id: "el1",
src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4",
});
expect(findClipForAsset([el], "assets/my file (1).mp4")).toBe(el);
});
it("strips query strings from src before matching", () => {
const el = makeEl({ id: "el1", src: "assets/clip.mp4?v=2" });
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
});
it("returns the element with the earliest start when multiple clips match", () => {
const later = makeEl({ id: "late", src: "assets/clip.mp4", start: 10 });
const earlier = makeEl({ id: "early", src: "assets/clip.mp4", start: 2 });
const first = makeEl({ id: "first", src: "assets/clip.mp4", start: 0 });
expect(findClipForAsset([later, earlier, first], "assets/clip.mp4")).toBe(first);
});
it("prefers the key over id when the element has both", () => {
// findClipForAsset returns the element object itself; callers do `clip.key ?? clip.id`
const el = makeEl({ id: "el1", key: "clip-key", src: "assets/img.png" });
const found = findClipForAsset([el], "assets/img.png");
expect(found?.key).toBe("clip-key");
});
it("skips elements with no src", () => {
const noSrc = makeEl({ id: "nosrc" });
const withSrc = makeEl({ id: "withsrc", src: "assets/img.png" });
expect(findClipForAsset([noSrc, withSrc], "assets/img.png")).toBe(withSrc);
});
});
describe("isPointerClick", () => {
it("returns true for zero movement", () => {
expect(isPointerClick(0, 0)).toBe(true);
});
it("returns true for movement within the threshold", () => {
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, 0)).toBe(true);
expect(isPointerClick(0, DRAG_THRESHOLD_PX - 1)).toBe(true);
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, DRAG_THRESHOLD_PX - 1)).toBe(true);
});
it("returns false at or beyond the threshold", () => {
expect(isPointerClick(DRAG_THRESHOLD_PX, 0)).toBe(false);
expect(isPointerClick(0, DRAG_THRESHOLD_PX)).toBe(false);
expect(isPointerClick(DRAG_THRESHOLD_PX + 10, 0)).toBe(false);
});
it("handles negative movement (pointer moved left/up)", () => {
expect(isPointerClick(-(DRAG_THRESHOLD_PX - 1), 0)).toBe(true);
expect(isPointerClick(-DRAG_THRESHOLD_PX, 0)).toBe(false);
});
});
@@ -0,0 +1,75 @@
/**
* Pure helpers for CapCut-style asset card click behavior.
*
* Clicking an asset card that is ALREADY ADDED to the timeline selects the
* corresponding clip. Clicking one NOT yet in the timeline opens a lightweight
* preview overlay. Both behaviors are gated on "this was a click, not a drag".
*
* Pure — unit-tested.
*/
import type { TimelineElement } from "../player/store/playerStore";
/**
* Find the TimelineElement that references `assetPath`, returning the one with
* the earliest start time when multiple clips share the same source.
*
* Matching mirrors `deriveUsedPaths` in AssetsTab: an element's `src` may be a
* fully-absolute URL, a server-relative `/api/projects/…/preview/…` path, a
* `./`-prefixed relative path, or a bare relative path — all normalised to the
* project-relative form that `assetPath` carries.
*
* Returns `null` when no element matches.
*/
export function findClipForAsset(
elements: TimelineElement[],
assetPath: string,
): TimelineElement | null {
let best: TimelineElement | null = null;
for (const el of elements) {
if (!el.src) continue;
if (normalizeSrc(el.src) !== assetPath) continue;
if (best === null || el.start < best.start) best = el;
}
return best;
}
/**
* Normalise a raw element `src` to the bare project-relative path so it can be
* compared against the asset-list strings (which have no leading slash, no
* origin, no query string).
*
* Mirrors the logic in `deriveUsedPaths` (AssetsTab.tsx) — keep in sync.
*/
function normalizeSrc(src: string): string {
let s = src;
try {
const u = new URL(s);
s = u.pathname;
} catch {
// Not an absolute URL — leave as-is
}
s = s
.replace(/^\/api\/projects\/[^/]+\/preview\//, "")
.replace(/^\.?\//, "")
.split(/[?#]/)[0];
try {
s = decodeURIComponent(s);
} catch {
// Malformed encoding — use as-is
}
return s;
}
/** Drag-detection threshold in pixels — movements within this are treated as clicks. */
export const DRAG_THRESHOLD_PX = 4;
/**
* Determine whether a pointer-up event should be treated as a click given the
* total pointer displacement since pointer-down.
*
* @param dx Horizontal distance moved in pixels.
* @param dy Vertical distance moved in pixels.
*/
export function isPointerClick(dx: number, dy: number): boolean {
return Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX;
}
@@ -0,0 +1,35 @@
/**
* Authored-opacity contract, studio side. The runtime stamps every graded
* element's authored inline opacity at document parse time (see
* installAuthoredOpacityCapture in @hyperframes/core); studio code that makes
* GSAP re-initialize tweens (soft reload, in-place patches) restores it so
* re-captures never bake a runtime transient in as a tween bound.
*/
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "@hyperframes/core/color-grading";
interface AttributeReader {
getAttribute(name: string): string | null;
}
/**
* The stamped authored inline opacity. Three-state:
* "0.98" — the authored value; "" — captured, authored none;
* null — never captured (unknown).
* Duck-typed so iframe-realm elements (no shared HTMLElement) work.
*/
export function readStampedAuthoredOpacity(element: AttributeReader): string | null {
return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
}
/**
* Write an authored inline opacity back: "" removes the property, a value sets
* it. Priority-lossy by design: the capture reads `style.opacity` (value only)
* and the write sets no priority, so an authored `opacity: X !important`
* round-trips as `opacity: X`. The only `!important` opacity in the pipeline
* is the color-grading runtime hide — a transient this contract exists to
* discard — and authored compositions don't `!important` their opacity.
*/
export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
if (authored === "") style.removeProperty("opacity");
else style.setProperty("opacity", authored);
}
@@ -0,0 +1,109 @@
// Imperative beat-edit operations driven by the player store. Times passed in
// are COMPOSITION coordinates (timeline seconds); they're converted to audio-file
// coordinates internally and strength is measured from the decoded audio.
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
import { isMusicTrack } from "./timelineInspector";
import { strengthAtTime, type MusicBeatAnalysis } from "@hyperframes/core/beats";
import {
addUserBeat,
removeUserBeat,
moveUserBeat,
mergeUserBeats,
type BeatEditState,
} from "./beatEditing";
/**
* Merge user beat edits into the detected analysis and remap from audio-file to
* composition coordinates (filtered to the music clip's visible range). Returns
* null when there's no music element, so beats never paint at wrong positions.
*/
export function remapBeatAnalysisToComposition(
beatAnalysis: MusicBeatAnalysis | null,
musicElement: Pick<TimelineElement, "src" | "start" | "playbackStart" | "duration"> | null,
beatEdits: BeatEditState | null,
): MusicBeatAnalysis | null {
if (!beatAnalysis || !musicElement) return null;
const merged = mergeUserBeats(
beatAnalysis.beatTimes,
beatAnalysis.beatStrengths,
beatEdits,
musicElement.src ?? null,
);
const playbackStart = musicElement.playbackStart ?? 0;
const clipEnd = playbackStart + musicElement.duration;
const offset = musicElement.start - playbackStart;
const times: number[] = [];
const strengths: number[] = [];
merged.times.forEach((t, i) => {
if (t >= playbackStart && t <= clipEnd) {
times.push(Math.round((t + offset) * 1000) / 1000);
strengths.push(merged.strengths[i] ?? 1);
}
});
return { ...beatAnalysis, beatTimes: times, beatStrengths: strengths };
}
function ctx() {
const s = usePlayerStore.getState();
const music = s.elements.find(isMusicTrack);
const analysis = s.beatAnalysis;
if (!music || !analysis || !music.src) return null;
return { s, music, analysis, src: music.src };
}
function compToAudio(start: number, playbackStart: number, compT: number): number {
return playbackStart + (compT - start);
}
// Clip length on the timeline. Falls back to source/analysis length when the
// media duration hasn't been probed yet (0), so the add window isn't degenerate.
function clipDuration(music: { duration: number; sourceDuration?: number }): number {
if (music.duration > 0) return music.duration;
if (music.sourceDuration && music.sourceDuration > 0) return music.sourceDuration;
return Number.POSITIVE_INFINITY;
}
/** True when a music track with analysis exists and the time is inside the clip. */
export function canAddBeatAt(compT: number): boolean {
const c = ctx();
if (!c) return false;
return compT >= c.music.start && compT <= c.music.start + clipDuration(c.music);
}
export function addBeatAtCompositionTime(compT: number): void {
const c = ctx();
if (!c) return;
const playbackStart = c.music.playbackStart ?? 0;
const audioT = compToAudio(c.music.start, playbackStart, compT);
if (audioT < playbackStart || audioT > playbackStart + clipDuration(c.music)) return;
const strength = strengthAtTime(c.analysis, audioT);
const next = addUserBeat(c.s.beatEdits, c.src, { time: audioT, strength }, c.analysis.beatTimes);
// No-op when the beat lands on an existing one — skip the undo entry + write.
if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "add beat");
}
export function deleteBeatAtCompositionTime(compT: number): void {
const c = ctx();
if (!c) return;
const audioT = compToAudio(c.music.start, c.music.playbackStart ?? 0, compT);
const next = removeUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, audioT);
// No-op when there was no beat to remove — skip the undo entry + write.
if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "delete beat");
}
export function moveBeatCompositionTime(fromCompT: number, toCompT: number): void {
const c = ctx();
if (!c) return;
const playbackStart = c.music.playbackStart ?? 0;
const fromAudio = compToAudio(c.music.start, playbackStart, fromCompT);
const toAudio = compToAudio(c.music.start, playbackStart, toCompT);
const clamped = Math.max(playbackStart, Math.min(playbackStart + clipDuration(c.music), toAudio));
const strength = strengthAtTime(c.analysis, clamped);
const next = moveUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, fromAudio, {
time: clamped,
strength,
});
// No-op when the move resolves to no change — skip the undo entry + write.
if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "move beat");
}
+136
View File
@@ -0,0 +1,136 @@
// User edits to the detected beat grid. All times are in AUDIO-FILE coordinates
// (offsets into the music source), matching MusicBeatAnalysis.beatTimes, so edits
// survive moving/trimming the music clip on the timeline.
export interface UserBeat {
time: number; // audio-file seconds
strength: number; // 01, measured from audio
}
export interface BeatEditState {
/** Music src these edits apply to; edits reset when the src changes. */
src: string;
/** Beats the user added (audio-file coords). */
added: UserBeat[];
/** Audio-file times of detected beats the user removed. */
removed: number[];
}
// Two beat times within this many seconds are treated as the same beat.
const MATCH_EPS = 0.015;
function near(a: number, b: number): boolean {
return Math.abs(a - b) < MATCH_EPS;
}
function activeEdits(edits: BeatEditState | null, src: string | null): BeatEditState | null {
return edits && src && edits.src === src ? edits : null;
}
/** Merge detected beats with user edits → effective beats (audio-file coords). */
export function mergeUserBeats(
detectedTimes: number[],
detectedStrengths: number[],
edits: BeatEditState | null,
src: string | null,
): { times: number[]; strengths: number[] } {
const e = activeEdits(edits, src);
const removed = e?.removed ?? [];
const merged: UserBeat[] = [];
for (let i = 0; i < detectedTimes.length; i++) {
const t = detectedTimes[i]!;
if (removed.some((r) => near(r, t))) continue;
merged.push({ time: t, strength: detectedStrengths[i] ?? 0.5 });
}
if (e) {
// Skip added beats that land on an already-present (detected) beat so an
// "add" near an existing beat doesn't create a near-duplicate.
for (const b of e.added) {
if (!merged.some((m) => near(m.time, b.time))) merged.push(b);
}
}
merged.sort((a, b) => a.time - b.time);
return { times: merged.map((b) => b.time), strengths: merged.map((b) => b.strength) };
}
function base(edits: BeatEditState | null, src: string): BeatEditState {
const e = activeEdits(edits, src);
return e
? { ...e, added: [...e.added], removed: [...e.removed] }
: { src, added: [], removed: [] };
}
/**
* Add a beat at an audio-file time. `detectedTimes` lets us no-op when the beat
* lands on an existing (non-removed) detected beat — otherwise the merge would
* drop it anyway and we'd record a phantom edit/undo/write. Returns the SAME
* reference when nothing changed so callers can skip persisting.
*/
export function addUserBeat(
edits: BeatEditState | null,
src: string,
beat: UserBeat,
detectedTimes: number[] = [],
): BeatEditState | null {
const active = activeEdits(edits, src);
// Already covered by a surviving detected beat → nothing to do.
const onLiveDetected =
detectedTimes.some((t) => near(t, beat.time)) &&
!(active?.removed ?? []).some((r) => near(r, beat.time));
if (onLiveDetected) return edits;
// Already an added beat here → nothing to do.
if ((active?.added ?? []).some((b) => near(b.time, beat.time))) return edits;
const next = base(edits, src);
// If a detected beat here was previously removed, drop the removal instead of stacking.
const ri = next.removed.findIndex((r) => near(r, beat.time));
if (ri >= 0) {
next.removed.splice(ri, 1);
return next;
}
next.added.push(beat);
return next;
}
/**
* Remove the beat nearest `time` — drops a user-added beat or hides a detected
* one. Returns the SAME reference when nothing changed (no added beat near
* `time`, and no live detected beat to hide) so callers can skip persisting a
* phantom edit/undo/write.
*/
export function removeUserBeat(
edits: BeatEditState | null,
src: string,
detectedTimes: number[],
time: number,
): BeatEditState | null {
const active = activeEdits(edits, src);
const hasAdded = (active?.added ?? []).some((b) => near(b.time, time));
const detected = detectedTimes.find((t) => near(t, time));
const alreadyHidden =
detected !== undefined && (active?.removed ?? []).some((r) => near(r, detected));
if (!hasAdded && (detected === undefined || alreadyHidden)) return edits;
const next = base(edits, src);
const ai = next.added.findIndex((b) => near(b.time, time));
if (ai >= 0) {
next.added.splice(ai, 1);
return next;
}
if (detected !== undefined && !next.removed.some((r) => near(r, detected))) {
next.removed.push(detected);
}
return next;
}
/** Move the beat at `fromTime` to `toBeat` (delete original, add new). */
export function moveUserBeat(
edits: BeatEditState | null,
src: string,
detectedTimes: number[],
fromTime: number,
toBeat: UserBeat,
): BeatEditState | null {
const removed = removeUserBeat(edits, src, detectedTimes, fromTime);
return addUserBeat(removed, src, toBeat, detectedTimes) ?? removed;
}
@@ -0,0 +1,24 @@
import {
type BlockCategory,
BLOCK_CATEGORIES,
resolveBlockCategory,
} from "@hyperframes/core/registry";
export type { BlockCategory };
export { BLOCK_CATEGORIES, resolveBlockCategory };
const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }> = {
transitions: { bg: "bg-blue-500/15", text: "text-blue-400", dot: "bg-blue-400" },
vfx: { bg: "bg-purple-500/15", text: "text-purple-400", dot: "bg-purple-400" },
social: { bg: "bg-pink-500/15", text: "text-pink-400", dot: "bg-pink-400" },
data: { bg: "bg-green-500/15", text: "text-green-400", dot: "bg-green-400" },
scenes: { bg: "bg-amber-500/15", text: "text-amber-400", dot: "bg-amber-400" },
captions: { bg: "bg-cyan-500/15", text: "text-cyan-400", dot: "bg-cyan-400" },
effects: { bg: "bg-rose-500/15", text: "text-rose-400", dot: "bg-rose-400" },
"text-effects": { bg: "bg-violet-500/15", text: "text-violet-400", dot: "bg-violet-400" },
"code-animation": { bg: "bg-emerald-500/15", text: "text-emerald-400", dot: "bg-emerald-400" },
};
export function getCategoryColors(category: BlockCategory) {
return COLOR_MAP[category];
}
+189
View File
@@ -0,0 +1,189 @@
import type { RegistryItem } from "@hyperframes/core/registry";
import type { TimelineElement } from "../player";
import {
insertTimelineAssetIntoSource,
resolveTimelineAssetInitialGeometry,
} from "./timelineAssetDrop";
import { collectHtmlIds } from "./studioHelpers";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import { saveProjectFilesWithHistory } from "./studioFileHistory";
import type { EditHistoryKind } from "./editHistory";
import { extendRootDurationInSource } from "./rootDuration";
function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
try {
const doc = iframe?.contentDocument;
if (!doc) return 0;
let max = 0;
for (const el of doc.body.querySelectorAll("*")) {
const z = parseInt(getComputedStyle(el).zIndex, 10);
if (Number.isFinite(z) && z > max) max = z;
}
return max;
} catch {
return 0;
}
}
interface AddBlockOptions {
projectId: string;
blockName: string;
activeCompPath: string | null;
placement?: { start: number; track: number };
visualPosition?: { left: number; top: number };
previewIframe?: HTMLIFrameElement | null;
currentTime?: number;
timelineElements: TimelineElement[];
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
refreshFileTree: () => Promise<void>;
reloadPreview: () => void;
showToast: (msg: string) => void;
}
function buildUniqueCompositionId(baseName: string, existingIds: Iterable<string>): string {
const idSet = new Set(existingIds);
if (!idSet.has(baseName)) return baseName;
let i = 2;
while (idSet.has(`${baseName}_${i}`)) i++;
return `${baseName}_${i}`;
}
export async function addBlockToProject(
opts: AddBlockOptions,
): Promise<{ block: RegistryItem; compositionPath: string } | null> {
const {
projectId,
blockName,
activeCompPath,
placement,
visualPosition,
timelineElements,
readProjectFile,
writeProjectFile,
recordEdit,
refreshFileTree,
reloadPreview,
showToast,
} = opts;
try {
const res = await fetch(`/api/projects/${projectId}/registry/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blockName }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Install failed" }));
showToast((err as { error?: string }).error || "Failed to install block");
return null;
}
const { written, block } = (await res.json()) as {
written: string[];
block: RegistryItem;
};
const compositionFile = written.find((f) => f.endsWith(".html")) ?? written[0];
if (!compositionFile) {
showToast("Installed but no composition file was written");
return null;
}
if (block.type === "hyperframes:component") {
const compContent = await readProjectFile(compositionFile);
const transparentContent = compContent.replace(
/background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g,
"background: transparent;",
);
if (transparentContent !== compContent) {
await writeProjectFile(compositionFile, transparentContent);
}
}
{
const targetPath = activeCompPath || "index.html";
const originalContent = await readProjectFile(targetPath);
const existingIds = collectHtmlIds(originalContent);
const compId = buildUniqueCompositionId(block.name, existingIds);
const resolvedTargetPath = targetPath || "index.html";
const relevantElements = timelineElements.filter(
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
);
const isBlock = block.type === "hyperframes:block";
const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
const currentTime = opts.currentTime ?? 0;
const start = placement
? Number(formatTimelineAttributeNumber(placement.start))
: Number(formatTimelineAttributeNumber(currentTime));
const blockDuration =
"duration" in block ? (block as { duration: number }).duration : undefined;
const duration =
blockDuration ??
relevantElements.reduce(
(max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)),
10,
);
const track =
placement?.track ??
(isBlock
? 0
: relevantElements.length > 0
? Math.max(...relevantElements.map((te) => te.track)) + 1
: 1);
const zIndex = getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1;
const width = hostDims.width;
const height = hostDims.height;
const left = visualPosition ? Math.round(visualPosition.left) : 0;
const top = visualPosition ? Math.round(visualPosition.top) : 0;
const subCompHtml = [
`<div`,
` data-composition-id="${compId}"`,
` data-composition-src="${compositionFile}"`,
` data-start="${formatTimelineAttributeNumber(start)}"`,
` data-duration="${formatTimelineAttributeNumber(duration)}"`,
` data-track-index="${track}"`,
` data-width="${width}"`,
` data-height="${height}"`,
` style="position: absolute; left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px; z-index: ${zIndex}"`,
`></div>`,
].join("\n");
let patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml);
patchedContent = extendRootDurationInSource(patchedContent, start + duration);
await saveProjectFilesWithHistory({
projectId,
label: `Add ${isBlock ? "block" : "component"}: ${block.title}`,
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
}
await refreshFileTree();
reloadPreview();
return { block, compositionPath: compositionFile };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to add block";
showToast(message);
return null;
}
}
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it } from "vitest";
import { __resetForTests, acquireCanvasNudgeKeys, canvasNudgeKeysClaimed } from "./canvasNudgeGate";
describe("canvasNudgeGate", () => {
// The claim counter is module-level state; reset it so an unbalanced claim in one
// test can't leak into the next.
beforeEach(() => {
__resetForTests();
});
it("reports claimed while at least one claim is held", () => {
expect(canvasNudgeKeysClaimed()).toBe(false);
const releaseA = acquireCanvasNudgeKeys();
const releaseB = acquireCanvasNudgeKeys();
expect(canvasNudgeKeysClaimed()).toBe(true);
releaseA();
expect(canvasNudgeKeysClaimed()).toBe(true);
releaseB();
expect(canvasNudgeKeysClaimed()).toBe(false);
});
it("makes release idempotent so an effect re-run cannot underflow", () => {
const release = acquireCanvasNudgeKeys();
release();
release();
expect(canvasNudgeKeysClaimed()).toBe(false);
const releaseNext = acquireCanvasNudgeKeys();
expect(canvasNudgeKeysClaimed()).toBe(true);
releaseNext();
});
});
@@ -0,0 +1,32 @@
/**
* Arrow-key ownership gate between the canvas nudge (DomEditOverlay) and the
* playback frame-step shortcuts (usePlaybackKeyboard). Both listen for arrow
* keys with window capture listeners, and their relative order depends on
* mount order (DomEditOverlay remounts when caption edit mode toggles), so
* `event.defaultPrevented` alone can't arbitrate. While a nudgeable canvas
* selection holds a claim, the playback handler skips ArrowLeft/ArrowRight.
*/
let claims = 0;
/** Claim the arrow keys for canvas nudging. Returns an idempotent release. */
export function acquireCanvasNudgeKeys(): () => void {
claims += 1;
let released = false;
return () => {
if (released) return;
released = true;
claims -= 1;
};
}
/** True while a nudgeable canvas selection owns the arrow keys. */
export function canvasNudgeKeysClaimed(): boolean {
return claims > 0;
}
/** Test-only: reset the module-level claim counter so leaked claims from one test
* can't bleed into the next (call in a `beforeEach`). */
export function __resetForTests(): void {
claims = 0;
}
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Window } from "happy-dom";
import { copyTextToClipboard } from "./clipboard";
function installDocument(execCommand: (command: string) => boolean): void {
const window = new Window();
Object.assign(window, { SyntaxError });
Object.defineProperty(window.document, "execCommand", {
configurable: true,
value: execCommand,
});
vi.stubGlobal("document", window.document);
}
function installNavigator(
writeText: (text: string) => Promise<void>,
userAgent = "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
): void {
vi.stubGlobal("navigator", {
clipboard: { writeText },
userAgent,
});
}
describe("copyTextToClipboard", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses the synchronous selection copy path first in Safari", async () => {
const execCommand = vi.fn((command: string) => command === "copy");
const writeText = vi.fn((_text: string) => Promise.resolve());
installDocument(execCommand);
installNavigator(
writeText,
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
);
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
expect(execCommand).toHaveBeenCalledWith("copy");
expect(writeText).not.toHaveBeenCalled();
expect(document.querySelector("textarea")).toBeNull();
});
it("uses navigator.clipboard first outside Safari", async () => {
const execCommand = vi.fn((command: string) => command === "copy");
const writeText = vi.fn((_text: string) => Promise.resolve());
installDocument(execCommand);
installNavigator(writeText);
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("copy me");
expect(execCommand).not.toHaveBeenCalled();
});
it("falls back to selection copy outside Safari when navigator.clipboard fails", async () => {
const execCommand = vi.fn((command: string) => command === "copy");
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
installDocument(execCommand);
installNavigator(writeText);
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("copy me");
expect(execCommand).toHaveBeenCalledWith("copy");
});
it("reports failure when both copy paths fail", async () => {
const execCommand = vi.fn(() => false);
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
installDocument(execCommand);
installNavigator(
writeText,
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
);
await expect(copyTextToClipboard("copy me")).resolves.toBe(false);
expect(execCommand).toHaveBeenCalledWith("copy");
expect(writeText).toHaveBeenCalledWith("copy me");
expect(document.querySelector("textarea")).toBeNull();
});
});
+57
View File
@@ -0,0 +1,57 @@
function copyWithSelection(text: string): boolean {
if (typeof document === "undefined" || !document.body || !document.execCommand) {
return false;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.width = "1px";
textarea.style.height = "1px";
textarea.style.padding = "0";
textarea.style.border = "0";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.focus({ preventScroll: true });
textarea.select();
textarea.setSelectionRange(0, text.length);
try {
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}
function shouldCopyWithSelectionFirst(): boolean {
if (typeof navigator === "undefined") return false;
const userAgent = navigator.userAgent;
return /Safari/i.test(userAgent) && !/Chrome|Chromium|CriOS|FxiOS|Edg|OPR/i.test(userAgent);
}
export async function copyTextToClipboard(text: string): Promise<boolean> {
const useSelectionFirst = shouldCopyWithSelectionFirst();
if (useSelectionFirst && copyWithSelection(text)) {
return true;
}
const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
if (clipboard?.writeText) {
try {
await clipboard.writeText(text);
return true;
} catch {
// Fall back below when the browser still allows synchronous copy.
}
}
return !useSelectionFirst && copyWithSelection(text);
}
@@ -0,0 +1,62 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
deduplicateIds,
serializeClipboardPayload,
deserializeClipboardPayload,
type ClipboardPayload,
} from "./clipboardPayload";
describe("deduplicateIds", () => {
it("renames ids that collide with existing ids", () => {
const html = '<div id="hero"><img id="photo" src="a.png" /></div>';
const existingIds = ["hero", "other"];
const result = deduplicateIds(html, existingIds);
expect(result).not.toContain('id="hero"');
expect(result).toContain('id="photo"');
expect(result).toMatch(/id="hero-\d+"/);
});
it("returns html unchanged when no collisions", () => {
const html = '<div id="unique"><p>hello</p></div>';
const result = deduplicateIds(html, ["other"]);
expect(result).toBe(html);
});
it("does not rewrite data-composition-id or other data-*-id attributes", () => {
const html = '<div data-composition-id="hero" data-clip-id="hero" id="hero">content</div>';
const result = deduplicateIds(html, ["hero"]);
expect(result).toContain('data-composition-id="hero"');
expect(result).toContain('data-clip-id="hero"');
expect(result).toMatch(/\sid="hero-\d+"/);
});
});
describe("serializeClipboardPayload / deserializeClipboardPayload", () => {
it("round-trips a timeline clip payload", () => {
const payload: ClipboardPayload = {
kind: "timeline-clip",
html: '<img id="photo" src="a.png" data-start="1" data-duration="3" />',
sourceFile: "index.html",
};
const json = serializeClipboardPayload(payload);
const parsed = deserializeClipboardPayload(json);
expect(parsed).toEqual(payload);
});
it("round-trips a dom-element payload", () => {
const payload: ClipboardPayload = {
kind: "dom-element",
html: '<div class="card"><p>Hello</p></div>',
sourceFile: "compositions/scene.html",
};
const json = serializeClipboardPayload(payload);
const parsed = deserializeClipboardPayload(json);
expect(parsed).toEqual(payload);
});
it("returns null for invalid JSON", () => {
expect(deserializeClipboardPayload("not json")).toBeNull();
expect(deserializeClipboardPayload('{"kind":"unknown"}')).toBeNull();
});
});
@@ -0,0 +1,169 @@
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";
export interface ClipboardPayload {
kind: "timeline-clip" | "dom-element";
html: string;
sourceFile: string;
originSelector?: string;
originSelectorIndex?: number;
}
interface SerializedPayload {
_marker: string;
kind: "timeline-clip" | "dom-element";
html: string;
sourceFile: string;
originSelector?: string;
originSelectorIndex?: number;
}
export function serializeClipboardPayload(payload: ClipboardPayload): string {
const data: SerializedPayload = {
_marker: CLIPBOARD_MARKER,
kind: payload.kind,
html: payload.html,
sourceFile: payload.sourceFile,
originSelector: payload.originSelector,
originSelectorIndex: payload.originSelectorIndex,
};
return JSON.stringify(data);
}
export function deserializeClipboardPayload(json: string): ClipboardPayload | null {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
if (obj._marker !== CLIPBOARD_MARKER) return null;
if (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null;
if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null;
return {
kind: obj.kind,
html: obj.html,
sourceFile: obj.sourceFile,
originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
originSelectorIndex:
typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,
};
}
/**
* Insert `newHtml` as a sibling immediately after the element matched by
* `selector` (at `selectorIndex`) in `source`. Falls back to inserting after
* the composition root if the selector doesn't match — so paste never silently
* drops the content.
*/
export function insertAsSibling(
source: string,
newHtml: string,
selector: string | undefined,
selectorIndex: number | undefined,
): string {
if (selector) {
const idx = selectorIndex ?? 0;
let matchCount = 0;
// Find the element by searching for its opening tag pattern.
// For id selectors like #foo, search for id="foo".
// For class selectors like .name-text, search for class="...name-text...".
// For attribute selectors like [data-composition-id="x"], search literally.
let searchPattern: RegExp | null = null;
if (selector.startsWith("#")) {
const id = selector.slice(1);
searchPattern = new RegExp(`<[a-z][^>]*\\bid="${id}"[^>]*>`, "gi");
} else if (selector.startsWith(".")) {
const cls = selector.slice(1);
searchPattern = new RegExp(`<[a-z][^>]*\\bclass="[^"]*\\b${cls}\\b[^"]*"[^>]*>`, "gi");
} else if (selector.startsWith("[")) {
const inner = selector.slice(1, -1);
searchPattern = new RegExp(`<[a-z][^>]*\\b${inner}[^>]*>`, "gi");
}
if (searchPattern) {
let match: RegExpExecArray | null;
while ((match = searchPattern.exec(source)) !== null) {
if (matchCount === idx) {
const insertPos = findClosingTagPosition(source, match.index);
if (insertPos > 0) {
return source.slice(0, insertPos) + "\n" + newHtml + source.slice(insertPos);
}
}
matchCount++;
}
}
}
// Fallback: insert after composition root opening tag (same as timeline clips)
const rootMatch = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
if (rootMatch && rootMatch.index != null) {
const insertAt = rootMatch.index + rootMatch[0].length;
return source.slice(0, insertAt) + newHtml + source.slice(insertAt);
}
return source + newHtml;
}
function findClosingTagPosition(html: string, openTagStart: number): number {
// Find the end of the opening tag
const openTagEnd = html.indexOf(">", openTagStart);
if (openTagEnd < 0) return -1;
// Self-closing tag?
if (html[openTagEnd - 1] === "/") return openTagEnd + 1;
// Extract the tag name
const tagNameMatch = html.slice(openTagStart).match(/^<([a-z][a-z0-9]*)/i);
if (!tagNameMatch) return -1;
const tagName = tagNameMatch[1]!;
// Walk forward counting open/close tags of the same name
let depth = 1;
let pos = openTagEnd + 1;
const openRe = new RegExp(`<${tagName}(?:\\s|>|/>)`, "gi");
const closeRe = new RegExp(`</${tagName}\\s*>`, "gi");
while (depth > 0 && pos < html.length) {
openRe.lastIndex = pos;
closeRe.lastIndex = pos;
const nextOpen = openRe.exec(html);
const nextClose = closeRe.exec(html);
if (!nextClose) return -1;
if (nextOpen && nextOpen.index < nextClose.index) {
// Check if it's self-closing
const selfCloseCheck = html.lastIndexOf("/", html.indexOf(">", nextOpen.index));
if (selfCloseCheck > nextOpen.index) {
pos = html.indexOf(">", nextOpen.index) + 1;
} else {
depth++;
pos = html.indexOf(">", nextOpen.index) + 1;
}
} else {
depth--;
if (depth === 0) return nextClose.index + nextClose[0].length;
pos = nextClose.index + nextClose[0].length;
}
}
return -1;
}
export function deduplicateIds(html: string, existingIds: string[]): string {
const existingSet = new Set(existingIds);
return html.replace(/(?<=\s)id="([^"]+)"/g, (full, id: string) => {
if (!existingSet.has(id)) return full;
let counter = 2;
while (existingSet.has(`${id}-${counter}`)) counter++;
const newId = `${id}-${counter}`;
existingSet.add(newId);
return `id="${newId}"`;
});
}
@@ -0,0 +1,2 @@
/** Matches the opening tag of a composition root element (e.g. `<div data-composition-id="main">`). */
export const COMPOSITION_ROOT_OPEN_TAG_RE = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
@@ -0,0 +1,50 @@
import type { DomEditSelection } from "../components/editor/domEditing";
import { getDomEditTargetKey } from "../components/editor/domEditing";
export function domEditSelectionsTargetSame(
a: DomEditSelection | null,
b: DomEditSelection | null,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
return getDomEditTargetKey(a) === getDomEditTargetKey(b);
}
export function domEditSelectionInGroup(
group: DomEditSelection[],
selection: DomEditSelection | null,
): boolean {
if (!selection) return false;
return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
}
export function toggleDomEditGroupSelection(
group: DomEditSelection[],
selection: DomEditSelection,
): DomEditSelection[] {
if (domEditSelectionInGroup(group, selection)) {
return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
}
return [...group, selection];
}
export function replaceDomEditGroupSelection(
group: DomEditSelection[],
selection: DomEditSelection,
): DomEditSelection[] {
let replaced = false;
const nextGroup = group.map((entry) => {
if (!domEditSelectionsTargetSame(entry, selection)) return entry;
replaced = true;
return selection;
});
return replaced ? nextGroup : [...group, selection];
}
export function seedDomEditGroupWithSelection(
group: DomEditSelection[],
selection: DomEditSelection | null,
): DomEditSelection[] {
if (!selection || domEditSelectionInGroup(group, selection)) return group;
return [selection, ...group];
}
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDomEditSaveQueue } from "./domEditSaveQueue";
import { StudioSaveHttpError } from "./studioSaveDiagnostics";
describe("dom edit save queue", () => {
afterEach(() => {
vi.useRealTimers();
});
it("opens the breaker after consecutive failures and rejects new work until reset", async () => {
const onOpen = vi.fn();
const onReset = vi.fn();
const queue = createDomEditSaveQueue({
failureThreshold: 2,
onOpen,
onReset,
});
await expect(
queue.enqueue(async () => {
throw new StudioSaveHttpError("Server down", 503);
}),
).rejects.toThrow("Server down");
await expect(
queue.enqueue(async () => {
throw new StudioSaveHttpError("Still down", 503);
}),
).rejects.toThrow("Still down");
expect(onOpen).toHaveBeenCalledWith({
consecutiveFailures: 2,
errorMessage: "Still down",
statusCode: 503,
});
let thirdRan = false;
await expect(
queue.enqueue(async () => {
thirdRan = true;
}),
).rejects.toThrow("Auto-save is paused");
expect(thirdRan).toBe(false);
queue.reset();
expect(onReset).toHaveBeenCalledOnce();
await queue.enqueue(async () => {
thirdRan = true;
});
expect(thirdRan).toBe(true);
queue.destroy();
});
it("keeps an open breaker paused even when already queued work succeeds", async () => {
const onOpen = vi.fn();
const onReset = vi.fn();
const queue = createDomEditSaveQueue({
failureThreshold: 1,
onOpen,
onReset,
});
let rejectFirst: ((error: Error) => void) | null = null;
let resolveFirstStarted: (() => void) | null = null;
const firstStarted = new Promise<void>((resolve) => {
resolveFirstStarted = resolve;
});
const first = queue.enqueue(
() =>
new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
resolveFirstStarted?.();
}),
);
const second = queue.enqueue(async () => {});
await firstStarted;
expect(rejectFirst).toBeTypeOf("function");
rejectFirst?.(new StudioSaveHttpError("Server down", 503));
await expect(first).rejects.toThrow("Server down");
await expect(second).resolves.toBeUndefined();
expect(onOpen).toHaveBeenCalledOnce();
expect(onReset).not.toHaveBeenCalled();
await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
queue.reset();
expect(onReset).toHaveBeenCalledOnce();
queue.destroy();
});
it("resets consecutive failures after a successful save", async () => {
const onOpen = vi.fn();
const queue = createDomEditSaveQueue({
failureThreshold: 2,
onOpen,
});
await expect(
queue.enqueue(async () => {
throw new Error("first failure");
}),
).rejects.toThrow("first failure");
await queue.enqueue(async () => {});
await expect(
queue.enqueue(async () => {
throw new Error("second failure after success");
}),
).rejects.toThrow("second failure after success");
expect(onOpen).not.toHaveBeenCalled();
queue.destroy();
});
});
@@ -0,0 +1,87 @@
import { getStudioSaveErrorMessage, getStudioSaveStatusCode } from "./studioSaveDiagnostics";
interface DomEditSaveQueueOpenEvent {
consecutiveFailures: number;
errorMessage: string;
statusCode: number | null;
}
interface DomEditSaveQueueOptions {
failureThreshold?: number;
onOpen?: (event: DomEditSaveQueueOpenEvent) => void;
onReset?: () => void;
}
export interface DomEditSaveQueue {
enqueue: (save: () => Promise<void>) => Promise<void>;
waitForIdle: () => Promise<void>;
reset: () => void;
destroy: () => void;
}
const DEFAULT_FAILURE_THRESHOLD = 5;
export class DomEditSaveQueueOpenError extends Error {
constructor() {
super("Auto-save is paused. Dismiss the warning to retry DOM edits.");
this.name = "DomEditSaveQueueOpenError";
}
}
export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): DomEditSaveQueue {
const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
let tail = Promise.resolve();
let consecutiveFailures = 0;
let breakerOpen = false;
const reset = (notify = true) => {
const wasOpen = breakerOpen;
consecutiveFailures = 0;
breakerOpen = false;
if (notify && wasOpen) options.onReset?.();
};
const open = (error: unknown) => {
if (breakerOpen) return;
breakerOpen = true;
options.onOpen?.({
consecutiveFailures,
errorMessage: getStudioSaveErrorMessage(error),
statusCode: getStudioSaveStatusCode(error) ?? null,
});
};
const run = async (save: () => Promise<void>) => {
try {
await save();
if (!breakerOpen) consecutiveFailures = 0;
} catch (error) {
consecutiveFailures += 1;
if (consecutiveFailures >= failureThreshold) open(error);
throw error;
}
};
return {
enqueue(save) {
if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
const queued = tail.catch(() => undefined).then(() => run(save));
tail = queued.then(
() => undefined,
() => undefined,
);
return queued;
},
async waitForIdle() {
await tail.catch(() => undefined);
},
reset,
destroy() {
reset(false);
},
};
}
@@ -0,0 +1,417 @@
import { describe, expect, it } from "vitest";
import {
buildEditHistoryEntry,
canApplyEditHistoryEntry,
createEmptyEditHistory,
hashEditHistoryContent,
pushEditHistoryEntry,
redoEditHistory,
undoEditHistory,
} from "./editHistory";
describe("edit history", () => {
it("pushes changed file snapshots onto undo and clears redo", () => {
const state = createEmptyEditHistory();
const entry = buildEditHistoryEntry({
projectId: "project-1",
label: "Move layer",
files: {
"index.html": {
before: '<div style="left: 0px"></div>',
after: '<div style="left: 20px"></div>',
},
},
now: 100,
id: "entry-1",
});
const withUndo = pushEditHistoryEntry(state, entry);
const redoEntry = buildEditHistoryEntry({
projectId: "project-1",
label: "Redoable edit",
files: {
"index.html": {
before: '<div style="left: 20px"></div>',
after: '<div style="left: 40px"></div>',
},
},
now: 200,
id: "redo-entry",
});
const next = pushEditHistoryEntry(
{
...withUndo,
redo: [redoEntry],
},
{
...entry,
id: "entry-2",
label: "Resize layer",
createdAt: 300,
},
);
expect(withUndo.undo).toHaveLength(1);
expect(withUndo.redo).toHaveLength(0);
expect(next.undo.map((item) => item.label)).toEqual(["Move layer", "Resize layer"]);
expect(next.redo).toHaveLength(0);
});
it("undo returns before contents and moves entry to redo", () => {
const entry = buildEditHistoryEntry({
projectId: "project-1",
label: "Move layer",
files: {
"index.html": { before: "before", after: "after" },
},
now: 100,
id: "entry-1",
});
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
const result = undoEditHistory(state, { "index.html": hashEditHistoryContent("after") }, 200);
expect(result.ok).toBe(true);
expect(result.filesToWrite).toEqual({ "index.html": "before" });
expect(result.state.undo).toHaveLength(0);
expect(result.state.redo.map((item) => item.id)).toEqual(["entry-1"]);
});
it("redo returns after contents and moves entry to undo", () => {
const entry = buildEditHistoryEntry({
projectId: "project-1",
label: "Move layer",
files: {
"index.html": { before: "before", after: "after" },
},
now: 100,
id: "entry-1",
});
const undone = undoEditHistory(
pushEditHistoryEntry(createEmptyEditHistory(), entry),
{ "index.html": hashEditHistoryContent("after") },
200,
).state;
const result = redoEditHistory(undone, { "index.html": hashEditHistoryContent("before") }, 300);
expect(result.ok).toBe(true);
expect(result.filesToWrite).toEqual({ "index.html": "after" });
expect(result.state.undo.map((item) => item.id)).toEqual(["entry-1"]);
expect(result.state.redo).toHaveLength(0);
});
it("blocks undo when current content hash does not match the recorded after hash", () => {
const entry = buildEditHistoryEntry({
projectId: "project-1",
label: "Move layer",
files: {
"index.html": { before: "before", after: "after" },
},
now: 100,
id: "entry-1",
});
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
const result = undoEditHistory(
state,
{ "index.html": hashEditHistoryContent("external") },
200,
);
expect(result.ok).toBe(false);
expect(result.reason).toBe("content-mismatch");
expect(result.state).toBe(state);
expect(result.filesToWrite).toEqual({});
});
it("can validate all files in a multi-file entry before applying", () => {
const entry = buildEditHistoryEntry({
projectId: "project-1",
label: "Update files",
files: {
"index.html": { before: "a", after: "b" },
"compositions/title.html": { before: "c", after: "d" },
},
now: 100,
id: "entry-1",
});
expect(
canApplyEditHistoryEntry(entry, "undo", {
"index.html": hashEditHistoryContent("b"),
"compositions/title.html": hashEditHistoryContent("d"),
}),
).toEqual({ ok: true });
expect(
canApplyEditHistoryEntry(entry, "undo", {
"index.html": hashEditHistoryContent("b"),
"compositions/title.html": hashEditHistoryContent("external"),
}),
).toEqual({ ok: false, reason: "content-mismatch", path: "compositions/title.html" });
});
it("prunes oldest undo entries when the limit is exceeded", () => {
let state = createEmptyEditHistory({ maxEntries: 2 });
for (let index = 1; index <= 3; index += 1) {
state = pushEditHistoryEntry(
state,
buildEditHistoryEntry({
projectId: "project-1",
label: `Edit ${index}`,
files: {
"index.html": { before: `${index - 1}`, after: `${index}` },
},
now: index,
id: `entry-${index}`,
}),
{ maxEntries: 2 },
);
}
expect(state.undo.map((entry) => entry.id)).toEqual(["entry-2", "entry-3"]);
});
it("coalesces source editor edits for the same file inside the coalesce window", () => {
const first = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit source",
kind: "source",
coalesceKey: "source:index.html",
files: {
"index.html": { before: "a", after: "b" },
},
now: 100,
id: "entry-1",
});
const second = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit source",
kind: "source",
coalesceKey: "source:index.html",
files: {
"index.html": { before: "b", after: "c" },
},
now: 300,
id: "entry-2",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), first),
second,
{ coalesceMs: 1000 },
);
expect(state.undo).toHaveLength(1);
expect(state.undo[0].id).toBe("entry-2");
expect(state.undo[0].files["index.html"].before).toBe("a");
expect(state.undo[0].files["index.html"].after).toBe("c");
});
it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
const timing = buildEditHistoryEntry({
projectId: "project-1",
label: "Resize timeline clip",
kind: "timeline",
coalesceKey: "timeline-resize:clip",
files: { "index.html": { before: "orig", after: "timing" } },
now: 0,
id: "timing",
});
// The server GSAP rewrite lands ~2s later, past the 300ms default window, but the
// follow-up carries a large coalesceMs so undo still collapses to a single step.
const gsap = buildEditHistoryEntry({
projectId: "project-1",
label: "Resize timeline clip",
kind: "timeline",
coalesceKey: "timeline-resize:clip",
coalesceMs: 10_000,
files: { "index.html": { before: "timing", after: "timing+gsap" } },
now: 2000,
id: "gsap",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), timing),
gsap,
);
expect(state.undo).toHaveLength(1);
expect(state.undo[0].files["index.html"].before).toBe("orig");
expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
});
it("does not merge a slow follow-up without the coalesceMs override", () => {
const timing = buildEditHistoryEntry({
projectId: "project-1",
label: "Resize timeline clip",
kind: "timeline",
coalesceKey: "timeline-resize:clip",
files: { "index.html": { before: "orig", after: "timing" } },
now: 0,
id: "timing",
});
const late = buildEditHistoryEntry({
projectId: "project-1",
label: "Resize timeline clip",
kind: "timeline",
coalesceKey: "timeline-resize:clip",
files: { "index.html": { before: "timing", after: "timing+gsap" } },
now: 2000,
id: "late",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), timing),
late,
);
expect(state.undo).toHaveLength(2);
});
it("coalesces entries with the same coalesceKey within the window (prop: format)", () => {
const first = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit title color",
kind: "source",
coalesceKey: "prop:title.color",
files: {
"index.html": { before: "a", after: "b" },
},
now: 100,
id: "entry-1",
});
const second = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit title color",
kind: "source",
coalesceKey: "prop:title.color",
files: {
"index.html": { before: "b", after: "c" },
},
now: 200,
id: "entry-2",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), first),
second,
{ coalesceMs: 1000 },
);
expect(state.undo).toHaveLength(1);
expect(state.undo[0].id).toBe("entry-2");
expect(state.undo[0].files["index.html"].before).toBe("a");
expect(state.undo[0].files["index.html"].after).toBe("c");
});
it("does not coalesce entries with different coalesceKeys (cross-prop separation)", () => {
const titleEdit = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit title color",
kind: "source",
coalesceKey: "prop:title.color",
files: {
"index.html": { before: "a", after: "b" },
},
now: 100,
id: "entry-title",
});
const bodyEdit = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit body color",
kind: "source",
coalesceKey: "prop:body.color",
files: {
"index.html": { before: "b", after: "c" },
},
now: 200,
id: "entry-body",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), titleEdit),
bodyEdit,
{ coalesceMs: 1000 },
);
expect(state.undo.map((e) => e.id)).toEqual(["entry-title", "entry-body"]);
});
it("does not coalesce source editor edits outside the coalesce window", () => {
const first = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit source",
kind: "source",
coalesceKey: "source:index.html",
files: {
"index.html": { before: "a", after: "b" },
},
now: 100,
id: "entry-1",
});
const second = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit source",
kind: "source",
coalesceKey: "source:index.html",
files: {
"index.html": { before: "b", after: "c" },
},
now: 5000,
id: "entry-2",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), first),
second,
{ coalesceMs: 1000 },
);
expect(state.undo.map((entry) => entry.id)).toEqual(["entry-1", "entry-2"]);
});
it("coalesces entries exactly at the coalesce boundary (delta === coalesceMs is inclusive)", () => {
const first = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit source",
kind: "source",
coalesceKey: "source:index.html",
files: {
"index.html": { before: "a", after: "b" },
},
now: 100,
id: "entry-1",
});
const second = buildEditHistoryEntry({
projectId: "project-1",
label: "Edit source",
kind: "source",
coalesceKey: "source:index.html",
files: {
"index.html": { before: "b", after: "c" },
},
now: 1100, // exactly coalesceMs=1000ms after first
id: "entry-2",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), first),
second,
{ coalesceMs: 1000 },
);
// Boundary is <=: delta of exactly 1000ms coalesces into one entry.
expect(state.undo).toHaveLength(1);
expect(state.undo[0].id).toBe("entry-2");
expect(state.undo[0].files["index.html"].before).toBe("a");
expect(state.undo[0].files["index.html"].after).toBe("c");
});
it.todo("gesture-start/commit collapses intermediate drag steps into one undo entry");
it.todo(
"origin:applyPatches edits are excluded from undo stack to prevent undo loops (requires SDK session)",
);
});
+224
View File
@@ -0,0 +1,224 @@
export type EditHistoryKind = "manual" | "motion" | "timeline" | "source";
export interface EditHistoryFileSnapshot {
before: string;
after: string;
beforeHash: string;
afterHash: string;
}
export interface EditHistoryEntry {
id: string;
projectId: string;
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
/** Per-entry coalesce window override (ms). Falls back to the reducer default. */
coalesceMs?: number;
createdAt: number;
files: Record<string, EditHistoryFileSnapshot>;
}
export interface EditHistoryState {
version: 1;
updatedAt: number;
undo: EditHistoryEntry[];
redo: EditHistoryEntry[];
}
export interface EditHistoryOptions {
maxEntries?: number;
coalesceMs?: number;
}
export interface BuildEditHistoryEntryInput {
id: string;
projectId: string;
label: string;
kind?: EditHistoryKind;
coalesceKey?: string;
coalesceMs?: number;
now: number;
files: Record<string, { before: string; after: string }>;
}
export type EditHistoryDirection = "undo" | "redo";
export type EditHistoryApplyCheck =
| { ok: true }
| { ok: false; reason: "content-mismatch"; path: string };
export type EditHistoryTransitionResult =
| {
ok: true;
state: EditHistoryState;
entry: EditHistoryEntry;
filesToWrite: Record<string, string>;
}
| {
ok: false;
reason: "empty" | "content-mismatch";
state: EditHistoryState;
filesToWrite: Record<string, string>;
path?: string;
};
const DEFAULT_MAX_ENTRIES = 100;
const DEFAULT_COALESCE_MS = 300;
export function hashEditHistoryContent(content: string): string {
let hash = 2166136261;
for (let index = 0; index < content.length; index += 1) {
hash ^= content.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
export function createEmptyEditHistory(_options?: EditHistoryOptions): EditHistoryState {
return {
version: 1,
updatedAt: 0,
undo: [],
redo: [],
};
}
export function buildEditHistoryEntry(input: BuildEditHistoryEntryInput): EditHistoryEntry {
const files: Record<string, EditHistoryFileSnapshot> = {};
for (const [path, snapshot] of Object.entries(input.files)) {
if (snapshot.before === snapshot.after) continue;
files[path] = {
before: snapshot.before,
after: snapshot.after,
beforeHash: hashEditHistoryContent(snapshot.before),
afterHash: hashEditHistoryContent(snapshot.after),
};
}
return {
id: input.id,
projectId: input.projectId,
label: input.label,
kind: input.kind ?? "manual",
coalesceKey: input.coalesceKey,
coalesceMs: input.coalesceMs,
createdAt: input.now,
files,
};
}
export function pushEditHistoryEntry(
state: EditHistoryState,
entry: EditHistoryEntry,
options?: EditHistoryOptions,
): EditHistoryState {
if (Object.keys(entry.files).length === 0) return state;
// The incoming entry's own window wins so a caller can guarantee a merge even when a
// slow async step (e.g. a server GSAP rewrite) sits between the two records.
const coalesceMs = entry.coalesceMs ?? options?.coalesceMs ?? DEFAULT_COALESCE_MS;
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
const previous = state.undo[state.undo.length - 1];
let undo = state.undo;
if (
previous &&
previous.coalesceKey &&
previous.coalesceKey === entry.coalesceKey &&
entry.createdAt - previous.createdAt <= coalesceMs
) {
const files: Record<string, EditHistoryFileSnapshot> = {};
for (const [path, snapshot] of Object.entries(entry.files)) {
const previousSnapshot = previous.files[path];
files[path] = previousSnapshot
? {
before: previousSnapshot.before,
after: snapshot.after,
beforeHash: previousSnapshot.beforeHash,
afterHash: snapshot.afterHash,
}
: snapshot;
}
undo = [...state.undo.slice(0, -1), { ...entry, files }];
} else {
undo = [...state.undo, entry];
}
return {
version: 1,
updatedAt: entry.createdAt,
undo: undo.slice(Math.max(0, undo.length - maxEntries)),
redo: [],
};
}
export function canApplyEditHistoryEntry(
entry: EditHistoryEntry,
direction: EditHistoryDirection,
currentHashes: Record<string, string>,
): EditHistoryApplyCheck {
for (const [path, snapshot] of Object.entries(entry.files)) {
const expected = direction === "undo" ? snapshot.afterHash : snapshot.beforeHash;
if (currentHashes[path] !== expected) {
return { ok: false, reason: "content-mismatch", path };
}
}
return { ok: true };
}
export function undoEditHistory(
state: EditHistoryState,
currentHashes: Record<string, string>,
now: number,
): EditHistoryTransitionResult {
const entry = state.undo[state.undo.length - 1];
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
const check = canApplyEditHistoryEntry(entry, "undo", currentHashes);
if (!check.ok) {
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
}
return {
ok: true,
entry,
filesToWrite: Object.fromEntries(
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.before]),
),
state: {
version: 1,
updatedAt: now,
undo: state.undo.slice(0, -1),
redo: [...state.redo, entry],
},
};
}
export function redoEditHistory(
state: EditHistoryState,
currentHashes: Record<string, string>,
now: number,
): EditHistoryTransitionResult {
const entry = state.redo[state.redo.length - 1];
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
const check = canApplyEditHistoryEntry(entry, "redo", currentHashes);
if (!check.ok) {
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
}
return {
ok: true,
entry,
filesToWrite: Object.fromEntries(
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.after]),
),
state: {
version: 1,
updatedAt: now,
undo: [...state.undo, entry],
redo: state.redo.slice(0, -1),
},
};
}
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it } from "vitest";
import { buildEditHistoryEntry, createEmptyEditHistory, pushEditHistoryEntry } from "./editHistory";
import {
createMemoryEditHistoryStorage,
loadEditHistoryState,
saveEditHistoryState,
} from "./editHistoryStorage";
describe("edit history storage", () => {
let storage: ReturnType<typeof createMemoryEditHistoryStorage>;
beforeEach(() => {
storage = createMemoryEditHistoryStorage();
});
it("returns empty history for projects without persisted state", async () => {
const state = await loadEditHistoryState(storage, "project-1");
expect(state).toEqual(createEmptyEditHistory());
});
it("saves and loads history per project", async () => {
const entry = buildEditHistoryEntry({
id: "entry-1",
projectId: "project-1",
label: "Move layer",
files: { "index.html": { before: "a", after: "b" } },
now: 100,
});
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
await saveEditHistoryState(storage, "project-1", state);
expect(await loadEditHistoryState(storage, "project-1")).toEqual(state);
expect(await loadEditHistoryState(storage, "project-2")).toEqual(createEmptyEditHistory());
});
});
@@ -0,0 +1,99 @@
import { createEmptyEditHistory, type EditHistoryState } from "./editHistory";
export interface EditHistoryStorageAdapter {
get(projectId: string): Promise<EditHistoryState | null>;
set(projectId: string, state: EditHistoryState): Promise<void>;
delete(projectId: string): Promise<void>;
}
const DB_NAME = "hyperframes-studio-edit-history";
const DB_VERSION = 1;
const STORE_NAME = "project-history";
export function createMemoryEditHistoryStorage(): EditHistoryStorageAdapter {
const states = new Map<string, EditHistoryState>();
return {
async get(projectId) {
return states.get(projectId) ?? null;
},
async set(projectId, state) {
states.set(projectId, structuredClone(state));
},
async delete(projectId) {
states.delete(projectId);
},
};
}
function openEditHistoryDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (!globalThis.indexedDB) {
reject(new Error("IndexedDB is not available"));
return;
}
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onerror = () => reject(request.error ?? new Error("Failed to open edit history db"));
request.onsuccess = () => resolve(request.result);
});
}
function withStore<T>(
mode: IDBTransactionMode,
callback: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return openEditHistoryDb().then(
(db) =>
new Promise<T>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const request = callback(tx.objectStore(STORE_NAME));
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
request.onsuccess = () => resolve(request.result);
tx.oncomplete = () => db.close();
tx.onerror = () => {
db.close();
reject(tx.error ?? new Error("IndexedDB transaction failed"));
};
}),
);
}
export function createIndexedDbEditHistoryStorage(): EditHistoryStorageAdapter {
return {
async get(projectId) {
return (
(await withStore<EditHistoryState | undefined>("readonly", (store) =>
store.get(projectId),
)) ?? null
);
},
async set(projectId, state) {
await withStore<IDBValidKey>("readwrite", (store) => store.put(state, projectId));
},
async delete(projectId) {
await withStore<undefined>("readwrite", (store) => store.delete(projectId));
},
};
}
export async function loadEditHistoryState(
storage: EditHistoryStorageAdapter,
projectId: string,
): Promise<EditHistoryState> {
const state = await storage.get(projectId);
return state ?? createEmptyEditHistory();
}
export async function saveEditHistoryState(
storage: EditHistoryStorageAdapter,
projectId: string,
state: EditHistoryState,
): Promise<void> {
await storage.set(projectId, state);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* GSAP access through an ELEMENT'S OWN window (the preview iframe's runtime),
* not the studio window. This is the single way studio gesture code touches an
* iframe element's GSAP position outside the commit pipeline — the resize
* anchor pin (apply + restore) and the post-commit live correction. The commit
* pipeline itself stays the owner of persisted values.
*/
type ElementGsapWindow = Window & {
gsap?: {
set?: (target: Element, vars: Record<string, number>) => void;
getProperty?: (target: Element, prop: string) => unknown;
};
};
function gsapOf(element: HTMLElement): ElementGsapWindow["gsap"] | undefined {
return (element.ownerDocument.defaultView as ElementGsapWindow | null)?.gsap;
}
/** Set the element's GSAP x/y. Returns false when no runtime is reachable. */
export function setElementGsapPosition(element: HTMLElement, x: number, y: number): boolean {
const gsap = gsapOf(element);
if (!gsap?.set) return false;
gsap.set(element, { x, y });
return true;
}
/** The element's GSAP numeric property, or null when unreadable. */
export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
const value = Number(gsapOf(element)?.getProperty?.(element, prop));
return Number.isFinite(value) ? value : null;
}
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
describe("frame capture utilities", () => {
it("builds a PNG capture URL for the master composition", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
expect(
buildFrameCaptureUrl({
projectId: "demo project",
compositionPath: null,
currentTime: 1.23456,
origin: "http://localhost:5194",
}),
).toBe(
"http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
);
vi.useRealTimers();
});
it("builds a safe filename from a nested composition path", () => {
expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { buildProjectApiPath } from "./projectRouting";
export interface FrameCaptureRequest {
projectId: string;
compositionPath: string | null;
currentTime: number;
origin?: string;
}
function normalizeCompositionPath(compositionPath: string | null): string {
return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
}
export function buildFrameCaptureUrl({
projectId,
compositionPath,
currentTime,
origin = window.location.origin,
}: FrameCaptureRequest): string {
const compPath = normalizeCompositionPath(compositionPath);
const url = new URL(
buildProjectApiPath(projectId, `/thumbnail/${encodeURIComponent(compPath)}`),
origin,
);
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
url.searchParams.set("format", "png");
url.searchParams.set("v", String(Date.now()));
return url.toString();
}
export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
const compPath = normalizeCompositionPath(compositionPath);
const base =
compPath
.split("/")
.pop()
?.replace(/\.html$/i, "") || "frame";
const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
return `${base}-${frameTime}s.png`;
}
+7
View File
@@ -0,0 +1,7 @@
// ponytail: crypto.randomUUID is undefined on plain HTTP non-localhost origins
export function generateId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { smoothGestureKeyframes } from "./gestureSmoother";
describe("smoothGestureKeyframes", () => {
it("returns input unchanged for ≤2 keyframes", () => {
const kfs = [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 100 } },
];
expect(smoothGestureKeyframes(kfs, 3)).toEqual(kfs);
});
it("pins first and last keyframes", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 999 } },
{ percentage: 100, properties: { x: 200 } },
];
const result = smoothGestureKeyframes(kfs, 3);
expect(result[0].properties.x).toBe(0);
expect(result[result.length - 1].properties.x).toBe(200);
});
it("smooths a zigzag into a gentler curve", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 25, properties: { x: 100 } },
{ percentage: 50, properties: { x: 0 } },
{ percentage: 75, properties: { x: 100 } },
{ percentage: 100, properties: { x: 0 } },
];
const result = smoothGestureKeyframes(kfs, 2);
const mid = result[2].properties.x as number;
// The sharp 0→100→0 zigzag should be softened — mid should be
// pulled toward the neighbors, not stay at exactly 0.
expect(mid).toBeGreaterThan(0);
expect(mid).toBeLessThan(100);
});
it("returns input unchanged with radius 0", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 999 } },
{ percentage: 100, properties: { x: 0 } },
];
expect(smoothGestureKeyframes(kfs, 0)).toEqual(kfs);
});
});
@@ -0,0 +1,46 @@
// ponytail: Gaussian-weighted moving average over gesture keyframes.
// Rounds off jittery corners from raw pointer input while preserving
// overall path shape. First/last keyframes are pinned (never moved).
// Upgrade path: Catmull-Rom spline if users need curve-fitted paths.
interface Keyframe {
percentage: number;
properties: Record<string, number | string>;
}
function gaussianWeight(distance: number, sigma: number): number {
return Math.exp(-(distance * distance) / (2 * sigma * sigma));
}
export function smoothGestureKeyframes(keyframes: Keyframe[], radius: number): Keyframe[] {
if (keyframes.length <= 2 || radius <= 0) return keyframes;
const sigma = radius / 2;
const numericKeys = new Set<string>();
for (const kf of keyframes) {
for (const [k, v] of Object.entries(kf.properties)) {
if (typeof v === "number") numericKeys.add(k);
}
}
if (numericKeys.size === 0) return keyframes;
return keyframes.map((kf, i) => {
if (i === 0 || i === keyframes.length - 1) return kf;
const smoothed: Record<string, number | string> = { ...kf.properties };
for (const key of numericKeys) {
let weightSum = 0;
let valueSum = 0;
for (let j = Math.max(0, i - radius); j <= Math.min(keyframes.length - 1, i + radius); j++) {
const v = keyframes[j].properties[key];
if (typeof v !== "number") continue;
// Weight by index distance, not time. Samples here are roughly evenly
// spaced, so for the small radius (3) this is fine; switch to a
// percentage-domain distance if the window ever grows much larger.
const w = gaussianWeight(j - i, sigma);
weightSum += w;
valueSum += v * w;
}
if (weightSum > 0) smoothed[key] = Math.round((valueSum / weightSum) * 1000) / 1000;
}
return { percentage: kf.percentage, properties: smoothed };
});
}
@@ -0,0 +1,169 @@
import { describe, expect, test } from "vitest";
import {
absoluteToPercentage,
absoluteToPercentageForAnimation,
findTweenAtTime,
isTimeWithinTween,
percentageToAbsolute,
percentageToAbsoluteForAnimation,
resolveTweenDuration,
resolveTweenStart,
} from "./globalTimeCompiler";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
function makeAnim(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
return {
id: "#el-to-0",
targetSelector: "#el",
method: "to",
position: 0,
properties: { x: 100 },
...overrides,
};
}
describe("absoluteToPercentage", () => {
test("mid-point of a tween", () => {
expect(absoluteToPercentage(0.5, 0, 2)).toBe(25);
});
test("tween with offset start", () => {
expect(absoluteToPercentage(1.0, 0.5, 1)).toBe(50);
});
test("clamps below tween start to 0%", () => {
expect(absoluteToPercentage(-1, 0, 2)).toBe(0);
});
test("clamps past tween end to 100%", () => {
expect(absoluteToPercentage(5, 0, 2)).toBe(100);
});
test("zero duration returns 0", () => {
expect(absoluteToPercentage(1, 0, 0)).toBe(0);
});
});
describe("percentageToAbsolute", () => {
test("converts percentage back to absolute time", () => {
expect(percentageToAbsolute(50, 0.5, 1)).toBe(1.0);
});
test("0% returns tween start", () => {
expect(percentageToAbsolute(0, 2, 3)).toBe(2);
});
test("100% returns tween end", () => {
expect(percentageToAbsolute(100, 2, 3)).toBe(5);
});
});
describe("isTimeWithinTween", () => {
test("time inside returns true", () => {
expect(isTimeWithinTween(0.5, 0, 2)).toBe(true);
});
test("time at start returns true", () => {
expect(isTimeWithinTween(0, 0, 2)).toBe(true);
});
test("time at end returns true", () => {
expect(isTimeWithinTween(2, 0, 2)).toBe(true);
});
test("time before returns false", () => {
expect(isTimeWithinTween(-0.1, 0, 2)).toBe(false);
});
test("time after returns false", () => {
expect(isTimeWithinTween(2.1, 0, 2)).toBe(false);
});
});
describe("resolveTweenStart", () => {
test("numeric position", () => {
expect(resolveTweenStart(makeAnim({ position: 1.5 }))).toBe(1.5);
});
test("parseable string position", () => {
expect(resolveTweenStart(makeAnim({ position: "2.5" }))).toBe(2.5);
});
test("unparseable string position returns null", () => {
expect(resolveTweenStart(makeAnim({ position: "myLabel" }))).toBeNull();
});
test("relative position +=0.5 returns null", () => {
expect(resolveTweenStart(makeAnim({ position: "+=0.5" }))).toBeNull();
});
});
describe("resolveTweenDuration", () => {
test("explicit duration", () => {
expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
});
test("missing duration defaults to GSAP default (0.5)", () => {
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5);
});
});
describe("findTweenAtTime", () => {
const anims = [
makeAnim({ id: "#el-to-0", position: 0, duration: 0.5 }),
makeAnim({ id: "#el-to-1", position: 1, duration: 1 }),
makeAnim({
id: "#other-to-0",
targetSelector: "#other",
position: 0,
duration: 2,
}),
];
test("finds tween at time within range", () => {
expect(findTweenAtTime(0.3, anims, "#el")?.id).toBe("#el-to-0");
});
test("finds second tween", () => {
expect(findTweenAtTime(1.5, anims, "#el")?.id).toBe("#el-to-1");
});
test("returns null for gap between tweens", () => {
expect(findTweenAtTime(0.7, anims, "#el")).toBeNull();
});
test("filters by selector", () => {
expect(findTweenAtTime(0.3, anims, "#other")?.id).toBe("#other-to-0");
});
test("returns null for unmatched selector", () => {
expect(findTweenAtTime(0.3, anims, "#missing")).toBeNull();
});
test("skips tweens with unresolvable string positions", () => {
const withLabel = [makeAnim({ id: "#el-to-0", position: "myLabel", duration: 1 })];
expect(findTweenAtTime(0.5, withLabel, "#el")).toBeNull();
});
});
describe("animation-level helpers", () => {
const anim = makeAnim({ position: 0.5, duration: 2 });
test("absoluteToPercentageForAnimation", () => {
expect(absoluteToPercentageForAnimation(1.5, anim)).toBe(50);
});
test("absoluteToPercentageForAnimation returns null for string position", () => {
const labelAnim = makeAnim({ position: "label" });
expect(absoluteToPercentageForAnimation(0.5, labelAnim)).toBeNull();
});
test("percentageToAbsoluteForAnimation", () => {
expect(percentageToAbsoluteForAnimation(50, anim)).toBe(1.5);
});
test("percentageToAbsoluteForAnimation returns null for string position", () => {
const labelAnim = makeAnim({ position: "+=1" });
expect(percentageToAbsoluteForAnimation(50, labelAnim)).toBeNull();
});
});
@@ -0,0 +1,78 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
export function absoluteToPercentage(
time: number,
tweenStart: number,
tweenDuration: number,
): number {
if (tweenDuration <= 0) return 0;
const raw = ((time - tweenStart) / tweenDuration) * 100;
return Math.max(0, Math.min(100, Math.round(raw * 10) / 10));
}
export function percentageToAbsolute(
pct: number,
tweenStart: number,
tweenDuration: number,
): number {
return tweenStart + (pct / 100) * tweenDuration;
}
export function isTimeWithinTween(
time: number,
tweenStart: number,
tweenDuration: number,
): boolean {
return time >= tweenStart && time <= tweenStart + tweenDuration;
}
export function resolveTweenStart(animation: GsapAnimation): number | null {
if (animation.resolvedStart != null) return animation.resolvedStart;
if (typeof animation.position === "number") return animation.position;
const parsed = Number.parseFloat(animation.position as string);
if (!Number.isNaN(parsed)) return parsed;
return null;
}
export function resolveTweenDuration(animation: GsapAnimation): number {
return animation.duration ?? 0.5;
}
export function findTweenAtTime(
time: number,
animations: GsapAnimation[],
selector: string,
): GsapAnimation | null {
for (const anim of animations) {
if (!matchesSelector(anim.targetSelector, selector)) continue;
const start = resolveTweenStart(anim);
if (start === null) continue;
const duration = resolveTweenDuration(anim);
if (isTimeWithinTween(time, start, duration)) return anim;
}
return null;
}
export function absoluteToPercentageForAnimation(
time: number,
animation: GsapAnimation,
): number | null {
const start = resolveTweenStart(animation);
if (start === null) return null;
const duration = resolveTweenDuration(animation);
return absoluteToPercentage(time, start, duration);
}
export function percentageToAbsoluteForAnimation(
pct: number,
animation: GsapAnimation,
): number | null {
const start = resolveTweenStart(animation);
if (start === null) return null;
const duration = resolveTweenDuration(animation);
return percentageToAbsolute(pct, start, duration);
}
function matchesSelector(tweenSelector: string, querySelector: string): boolean {
return tweenSelector.split(",").some((part) => part.trim() === querySelector);
}
@@ -0,0 +1,449 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi } from "vitest";
import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload";
const SCRIPT_TEXT = `
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { opacity: 0.8 });
window.__timelines["root"] = tl;
`;
const MOTION_PATH_SCRIPT_TEXT = `
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] } });
window.__timelines["root"] = tl;
`;
function buildMockIframe(overrides: Record<string, unknown> = {}) {
const scriptEl = document.createElement("script");
scriptEl.textContent =
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
const container = document.createElement("div");
container.appendChild(scriptEl);
const mockTimeline = { kill: vi.fn(), pause: vi.fn() };
const contentWindow = {
gsap: { timeline: vi.fn() },
__hfForceTimelineRebind: vi.fn(),
__timelines: { root: mockTimeline } as Record<string, typeof mockTimeline>,
__player: { getTime: () => 2.0, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
__hfSuppressSceneMutations: undefined as undefined | (<T>(fn: () => T) => T),
...overrides,
};
// Intercept appendChild: when a <script> is appended, simulate execution by
// repopulating __timelines (mimicking what the real GSAP script would do).
const realAppendChild = container.appendChild.bind(container);
container.appendChild = <T extends Node>(node: T): T => {
const result = realAppendChild(node);
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
// Simulate the script populating __timelines
const cw = contentWindow as { __timelines?: Record<string, unknown> };
if (cw.__timelines) {
cw.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
}
}
return result;
};
const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
return {
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
contentWindow,
mockTimeline,
};
}
describe("applySoftReload", () => {
it('returns "cannot-soft-reload" when iframe is null', () => {
expect(applySoftReload(null, SCRIPT_TEXT)).toBe("cannot-soft-reload");
});
it('returns "cannot-soft-reload" when scriptText is empty', () => {
const { iframe } = buildMockIframe();
expect(applySoftReload(iframe, "")).toBe("cannot-soft-reload");
});
it('returns "cannot-soft-reload" when gsap is not on iframe window', () => {
const { iframe } = buildMockIframe({ gsap: undefined });
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
});
it('returns "cannot-soft-reload" when __hfForceTimelineRebind is missing', () => {
const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
});
it('returns "cannot-soft-reload" when the script registers no scopable key', () => {
// No __timelines["key"] pattern → targetKeys is empty → can't scope safely.
const { iframe } = buildMockIframe();
expect(applySoftReload(iframe, 'gsap.to("#box", { x: 1 });')).toBe("cannot-soft-reload");
});
it("kills existing timelines, rebinds, and re-seeks on success", () => {
const { iframe, contentWindow, mockTimeline } = buildMockIframe();
const result = applySoftReload(iframe, SCRIPT_TEXT);
expect(result).toBe("applied");
expect(mockTimeline.kill).toHaveBeenCalled();
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
});
it("seeks to the caller-supplied currentTime override instead of the iframe's own __player.getTime()", () => {
// Regression: the iframe's raw __player.getTime() (2.0 here, per the mock)
// can desync from the studio's authoritative scrub position — e.g. a
// keyframe-node drag parks the playhead via the store before this reload's
// async commit resolves. The rebuilt timeline must re-seek to the caller's
// value, not the iframe's possibly-stale one.
const { iframe, contentWindow } = buildMockIframe();
const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 });
expect(result).toBe("applied");
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
});
it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => {
// Repro: an element dragged via gsap.set whose keyframes were then removed is
// no longer a timeline child, so the timeline-children sweep misses it. Its
// stale inline transform must still be cleared so it snaps back to its source
// (overlay) position instead of rendering offset.
const orphan = document.createElement("div");
orphan.style.cssText = "left: 1240px; top: 200px; transform: translate(449px, 0px)";
Object.assign(orphan, { _gsap: {} }); // GSAP cache marker (set by gsap.set)
const scriptEl = document.createElement("script");
scriptEl.textContent = 'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });';
const container = document.createElement("div");
container.appendChild(scriptEl);
const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
querySelectorAll: (sel: string) =>
sel === "script:not([src])" ? [scriptEl] : sel === "[style*='transform']" ? [orphan] : [],
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
applySoftReload(iframe, SCRIPT_TEXT);
expect(orphan.style.transform).toBe(""); // stale GSAP transform stripped
expect(orphan.style.left).toBe("1240px"); // authored CSS base preserved
});
it("wraps execution in __hfSuppressSceneMutations when available", () => {
let suppressionCalled = false;
const { iframe } = buildMockIframe({
__hfSuppressSceneMutations: <T>(fn: () => T): T => {
suppressionCalled = true;
return fn();
},
});
const result = applySoftReload(iframe, SCRIPT_TEXT);
expect(result).toBe("applied");
expect(suppressionCalled).toBe(true);
});
it('returns "applied" when the re-run re-registers the script\'s expected key', () => {
// SCRIPT_TEXT registers __timelines["root"]; buildMockIframe's appendChild
// shim repopulates `root` on execution. The hardened verify checks the
// expected target key is present (not merely "some key"), so a correct re-run
// reliably reports "applied" — it doesn't spuriously hit the transient window.
const { iframe, contentWindow } = buildMockIframe();
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
expect(contentWindow.__timelines.root).toBeDefined();
});
it('returns "verify-failed" (transient) when the re-run leaves the key empty', () => {
// No appendChild shim repopulation: the body container has no shim, so the
// re-run kills __timelines["root"] and the new script doesn't re-register it.
// That is the TRANSIENT post-run window — surfaced as "verify-failed" so
// callers know NOT to escalate (the live gsap.set already shows the value).
const scriptEl = document.createElement("script");
scriptEl.textContent = 'window.__timelines["root"] = gsap.timeline();';
const container = document.createElement("div"); // no appendChild shim
container.appendChild(scriptEl);
const { iframe } = buildMockIframe();
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("verify-failed");
});
it("editing composition A leaves composition B's timeline intact (scoped kill)", () => {
// Two comps live side by side; the soft reload only re-runs comp "root".
// Comp "subscene" must survive untouched — the regression the full remount
// (re-inline) used to cause.
const subsceneTimeline = { kill: vi.fn(), pause: vi.fn() };
const { iframe, contentWindow, mockTimeline } = buildMockIframe({
__timelines: {
root: { kill: vi.fn(), pause: vi.fn() },
subscene: subsceneTimeline,
} as Record<string, { kill: ReturnType<typeof vi.fn>; pause: ReturnType<typeof vi.fn> }>,
});
void mockTimeline;
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
// Comp B was never killed and is still registered.
expect(subsceneTimeline.kill).not.toHaveBeenCalled();
expect(contentWindow.__timelines.subscene).toBe(subsceneTimeline);
});
it("runs synchronously (no async plugin load) when MotionPathPlugin is already present", () => {
// The preview bootstrap pre-loads MotionPathPlugin, so win.MotionPathPlugin
// is set before any motion-path edit. The soft reload must then execute the
// script inline — no CDN <script> appended to <head>, the timeline is
// repopulated synchronously, and verifyTimelinesPopulated reports the real
// result (not the optimistic-true async path).
const headAppends: Node[] = [];
const head = document.createElement("div");
const realHeadAppend = head.appendChild.bind(head);
head.appendChild = <T extends Node>(node: T): T => {
headAppends.push(node);
return realHeadAppend(node);
};
const { iframe, contentWindow } = buildMockIframe({ MotionPathPlugin: {} });
(iframe.contentDocument as unknown as { head: unknown }).head = head;
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT);
expect(result).toBe("applied");
// No CDN plugin <script> was appended to <head> — ran inline.
expect(headAppends.filter((n) => n instanceof HTMLScriptElement)).toHaveLength(0);
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
expect(contentWindow.__timelines.root).toBeDefined();
});
it("falls back to the async plugin load when MotionPathPlugin is genuinely absent", () => {
const head = document.createElement("div");
const appendedScripts: HTMLScriptElement[] = [];
const realHeadAppend = head.appendChild.bind(head);
head.appendChild = <T extends Node>(node: T): T => {
if (node instanceof HTMLScriptElement) appendedScripts.push(node);
return realHeadAppend(node);
};
// gsap present but MotionPathPlugin unset → async load path.
const { iframe, contentWindow } = buildMockIframe({
MotionPathPlugin: undefined,
gsap: { timeline: vi.fn(), registerPlugin: vi.fn() },
});
(iframe.contentDocument as unknown as { head: unknown }).head = head;
const onAsyncFailure = vi.fn();
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure });
// Optimistically "applied" (script will run once the plugin loads) — and the
// script has NOT executed yet, so the timeline isn't rebound synchronously.
expect(result).toBe("applied");
expect(appendedScripts).toHaveLength(1);
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
// onerror must NOT run the script (that would reference a missing plugin) —
// it escalates via onAsyncFailure so the caller can full-reload to recover,
// and clears the in-flight loading flag.
appendedScripts[0]!.onerror?.(new Event("error"));
expect(onAsyncFailure).toHaveBeenCalledTimes(1);
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
});
it('returns "cannot-soft-reload" when multiple GSAP scripts exist (ambiguous)', () => {
const script1 = document.createElement("script");
script1.textContent = "const tl = gsap.timeline({ paused: true });";
const script2 = document.createElement("script");
script2.textContent = 'tl.to("#other", { x: 10 });';
const container = document.createElement("div");
container.appendChild(script1);
container.appendChild(script2);
const { iframe } = buildMockIframe();
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [script1, script2] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
};
// Multiple scripts, none registering "root" → can't identify what to replace
// → structural failure that genuinely needs a full reload.
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
});
});
function buildBootstrapIframe(overrides: Record<string, unknown> = {}) {
const head = document.createElement("div");
const appendedScripts: HTMLScriptElement[] = [];
const realHeadAppend = head.appendChild.bind(head);
head.appendChild = <T extends Node>(node: T): T => {
if (node instanceof HTMLScriptElement) appendedScripts.push(node);
return realHeadAppend(node);
};
const registerPlugin = vi.fn();
const contentWindow = {
gsap: { registerPlugin } as Record<string, unknown> | undefined,
MotionPathPlugin: undefined as unknown,
__hfMotionPathPluginLoading: undefined as boolean | undefined,
...overrides,
};
const contentDocument = {
createElement: (tag: string) => document.createElement(tag),
head,
};
return {
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
contentWindow,
appendedScripts,
registerPlugin,
};
}
describe("ensureMotionPathPluginLoaded", () => {
it("no-ops when the iframe is null", () => {
expect(() => ensureMotionPathPluginLoaded(null)).not.toThrow();
});
it("no-ops when gsap is unavailable", () => {
const { iframe, appendedScripts } = buildBootstrapIframe({ gsap: undefined });
ensureMotionPathPluginLoaded(iframe);
expect(appendedScripts).toHaveLength(0);
});
it("appends the plugin script once and registers it on load", () => {
const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
ensureMotionPathPluginLoaded(iframe);
expect(appendedScripts).toHaveLength(1);
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
expect(contentWindow.__hfMotionPathPluginLoading).toBe(true);
// Simulate the CDN load completing; the plugin is now present.
contentWindow.MotionPathPlugin = {};
appendedScripts[0]!.onload?.(new Event("load"));
expect(registerPlugin).toHaveBeenCalledWith(contentWindow.MotionPathPlugin);
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
});
it("is idempotent: a second call while loading does not append a second script", () => {
const { iframe, appendedScripts } = buildBootstrapIframe();
ensureMotionPathPluginLoaded(iframe);
ensureMotionPathPluginLoaded(iframe);
expect(appendedScripts).toHaveLength(1);
});
it("registers an already-present plugin without appending a script", () => {
const plugin = {};
const { iframe, appendedScripts, registerPlugin } = buildBootstrapIframe({
MotionPathPlugin: plugin,
});
ensureMotionPathPluginLoaded(iframe);
expect(appendedScripts).toHaveLength(0);
expect(registerPlugin).toHaveBeenCalledWith(plugin);
});
it("clears the loading flag and still resolves when the CDN load errors", () => {
const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe();
ensureMotionPathPluginLoaded(iframe);
appendedScripts[0]!.onerror?.(new Event("error"));
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
// A subsequent call can retry (plugin still absent, flag cleared).
ensureMotionPathPluginLoaded(iframe);
expect(appendedScripts).toHaveLength(2);
});
});
// The authored-opacity restore: before the script re-runs (and its tweens
// re-capture bounds), every animated element's inline opacity must be put back
// to its AUTHORED value — from the after-write file HTML when provided, else
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
describe("applySoftReload authored-opacity restore", () => {
function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
const scriptEl = document.createElement("script");
scriptEl.textContent =
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
const tl = {
kill: vi.fn(),
pause: vi.fn(),
getChildren: () => [{ targets: () => [el] }],
};
const contentWindow = {
gsap: { timeline: vi.fn(), set: vi.fn() },
__hfForceTimelineRebind: vi.fn(),
__timelines: { root: tl } as Record<string, unknown>,
__player: { getTime: () => 2.0, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
...overrides,
};
const container = document.createElement("div");
container.appendChild(scriptEl);
// Intercept only POST-SETUP appends: simulate the re-run script
// repopulating __timelines (as in buildMockIframe).
const realAppendChild = container.appendChild.bind(container);
container.appendChild = <T extends Node>(node: T): T => {
const result = realAppendChild(node);
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
contentWindow.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
}
return result;
};
const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
};
return { iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement };
}
/** Run one restore cycle over `el` and return the final inline opacity. */
function restoreOpacity(el: HTMLElement, authoredHtml?: string): string {
const { iframe } = buildIframeWithTarget(el);
expect(applySoftReload(iframe, SCRIPT_TEXT, authoredHtml ? { authoredHtml } : {})).toBe(
"applied",
);
return el.style.getPropertyValue("opacity");
}
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-id", "hf-1");
el.style.setProperty("opacity", "0", "important"); // the grading hide
const opacity = restoreOpacity(
el,
'<html><body><img data-hf-id="hf-1" style="opacity: 0.98"></body></html>',
);
expect(opacity).toBe("0.98");
expect(el.style.getPropertyPriority("opacity")).toBe("");
});
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-authored-opacity", "0.75");
el.style.opacity = "0.123"; // mid-flight tween transient
expect(restoreOpacity(el)).toBe("0.75");
});
it("an empty stamp (authored none) removes the inline opacity", () => {
const el = document.createElement("img");
el.setAttribute("data-hf-authored-opacity", "");
el.style.opacity = "0";
expect(restoreOpacity(el)).toBe("");
});
});
+447
View File
@@ -0,0 +1,447 @@
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
type IframeWindow = Window & {
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
__player?: { getTime?: () => number; seek?: (t: number) => void };
__hfForceTimelineRebind?: () => void;
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
__hfStudioManualEditsApply?: () => void;
// Set while a MotionPathPlugin <script> is being fetched, so overlapping soft
// reloads (each needing the plugin) don't queue duplicate plugin scripts that
// re-flash the iframe. Cleared once the plugin loads or errors.
__hfMotionPathPluginLoading?: boolean;
gsap?: {
timeline?: (...args: unknown[]) => unknown;
registerPlugin?: (...plugins: unknown[]) => unknown;
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
};
MotionPathPlugin?: unknown;
};
/**
* CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
* bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
* version is pinned in a single place.
*/
const MOTION_PATH_PLUGIN_CDN =
"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
/**
* Pre-load + register MotionPathPlugin ONCE in the preview iframe so
* `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
* preview bootstrap (NLELayout's onIframeLoad) on every iframe load.
*
* Why: when a user ADDS a motion path to a composition that never used one, the
* plugin isn't loaded, so the first soft reload takes the async `<script src>`
* load path — the timeline is killed/cleared while the CDN load is pending,
* producing a visible flash. Loading it eagerly here means the soft reload runs
* synchronously and `needsMotionPath && !win.MotionPathPlugin` never fires for
* studio edits.
*
* Idempotent (no-ops once the plugin is present or already loading) and
* defensive: no-ops without gsap/registerPlugin and tolerates a CDN failure
* (the soft-reload async fallback in applySoftReload still covers that case).
*/
export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
if (!iframe?.contentWindow || !iframe.contentDocument) return;
const win = iframe.contentWindow as IframeWindow;
const doc = iframe.contentDocument;
// Already registered (composition shipped its own plugin, or a prior bootstrap
// ran) — register it on gsap to be safe, then bail.
if (win.MotionPathPlugin) {
try {
if (win.gsap?.registerPlugin) win.gsap.registerPlugin(win.MotionPathPlugin);
} catch {}
return;
}
if (!win.gsap?.registerPlugin) return;
// A load is already in flight for this iframe — don't queue a second script.
if (win.__hfMotionPathPluginLoading) return;
try {
win.__hfMotionPathPluginLoading = true;
const pluginScript = doc.createElement("script");
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
const finalize = () => {
win.__hfMotionPathPluginLoading = false;
try {
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
win.gsap.registerPlugin(win.MotionPathPlugin);
}
} catch {}
};
pluginScript.onload = finalize;
pluginScript.onerror = finalize;
doc.head.appendChild(pluginScript);
} catch {
win.__hfMotionPathPluginLoading = false;
}
}
function isGsapScript(text: string): boolean {
return (
text.includes("gsap.timeline") ||
text.includes("__timelines") ||
text.includes(".to(") ||
text.includes(".set(")
);
}
function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
const results: HTMLScriptElement[] = [];
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
for (const script of scripts) {
if (isGsapScript(script.textContent || "")) results.push(script);
}
return results;
}
/**
* Extract the GSAP timeline script text from a serialized HTML document, for
* feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
* are present (ambiguous — caller should fall back to a full reload), matching
* applySoftReload's own single-script requirement.
*/
export function extractGsapScriptText(html: string): string | null {
const doc = new DOMParser().parseFromString(html, "text/html");
const scripts = findGsapScriptElements(doc);
if (scripts.length !== 1) return null;
return scripts[0].textContent || null;
}
/**
* Confirm the re-run repopulated the timeline(s) this script owns. We check the
* EXPECTED keys (the ones the script re-registers), not merely "any key": a
* scoped soft reload only re-runs ONE composition, so the right success signal is
* "my target keys are back", not "the global map is non-empty". Checking the
* exact keys avoids the transient false where the global map momentarily looks
* empty right after the re-run — the spurious trigger of the full-remount fallback.
*/
function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): boolean {
const timelines = win.__timelines;
if (!timelines) return false;
if (targetKeys.length > 0) {
return targetKeys.every((key) => timelines[key] != null);
}
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
}
/**
* Outcome of a soft-reload attempt. Callers must distinguish PERMANENT failures
* (the preview genuinely can't be soft-updated — escalate to a full reload) from
* the TRANSIENT post-run empty-timeline window (the live `gsap.set` already shows
* the correct value — do NOT escalate; a remount would re-flash the WebGL context
* and revert subcomposition keyframes):
*
* - `"applied"` — the script ran (or is deferred to the async plugin
* load and WILL run). The preview is/will be correct.
* - `"verify-failed"` — TRANSIENT: the re-run happened but `__timelines`
* momentarily read empty. Live state is correct → do
* NOT escalate. (Was a bare `false` before.)
* - `"cannot-soft-reload"` — PERMANENT/STRUCTURAL: no gsap runtime, no rebind
* hook, no scopable target key, or no script element
* to replace. The preview is stale/broken → escalate.
*
* The async MotionPath-plugin load failure is still surfaced via
* `onAsyncFailure` (it fires after this returned `"applied"` optimistically).
*/
export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload";
/**
* Replace the GSAP script in the live iframe without reloading. This preserves
* the WebGL context and shader transition cache.
*
* Scoped to root-document GSAP scripts only — scripts inside `<template>`
* elements (sub-compositions) are not visible to `querySelectorAll` and will
* fall back to a full iframe reload.
*
* Returns `"cannot-soft-reload"` (caller should full-reload) when:
* - The iframe or GSAP runtime isn't available
* - The rebind hook isn't installed
* - The script registers no scopable `__timelines` key
* - No GSAP script element exists in the live DOM
* - The synchronous re-run threw
*
* Returns `"verify-failed"` when the re-run executed but the target timeline
* keys read empty in the transient post-run window (live state is still correct).
*
* `onAsyncFailure` is invoked when the soft reload was deferred to load the
* MotionPath plugin (so this returned `"applied"` optimistically) but the plugin
* `<script>` then failed to load — the iframe is left without the plugin and the
* caller should perform a full reload to recover. It never fires on the
* synchronous paths.
*/
export interface SoftReloadOptions {
/** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
onAsyncFailure?: () => void;
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
currentTimeOverride?: number;
/** After-write file HTML — the primary source for authored-opacity restore. */
authoredHtml?: string;
}
export function applySoftReload(
iframe: HTMLIFrameElement | null,
scriptText: string,
options: SoftReloadOptions = {},
): SoftReloadResult {
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
if (!iframe || !scriptText) return "cannot-soft-reload";
const win = iframe.contentWindow as IframeWindow | null;
const doc = iframe.contentDocument;
if (!win || !doc) return "cannot-soft-reload";
if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
// Which composition(s) does this script rebuild? A soft reload re-runs ONE
// composition's GSAP script, which re-registers its own window.__timelines[key].
// In a multi-composition preview (top-level + inlined subcompositions) each
// composition owns a separate timeline keyed by its id, and they're all children
// of the global timeline — so tearing down ALL of them (or the global timeline's
// children) and re-running a single script wipes every OTHER composition,
// reverting its edits. Scope the teardown to the keys THIS script re-registers.
const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
.map((m) => m[1]!)
.filter((key) => key !== "__proxied");
if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
const gsapScripts = findGsapScriptElements(doc);
if (gsapScripts.length === 0) return "cannot-soft-reload";
// Remove only the stale script element(s) that registered a target key; one we
// can't match in the doc is left alone (re-running appends a fresh element).
const staleScripts = gsapScripts.filter((script) =>
targetKeys.some((key) => {
const text = script.textContent || "";
return text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`);
}),
);
// Multiple GSAP scripts exist but none registers a key this script owns — we
// can't identify which element to replace (ambiguous, matching
// extractGsapScriptText's single-script requirement). Escalate to a full reload
// rather than killing the target timeline and appending an orphan script.
if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
// Prefer the caller-supplied scrub position (the studio's own authoritative
// currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`:
// the two can desync (a keyframe-node drag parks the playhead via the store
// BEFORE this reload's async commit resolves, and the iframe's own GSAP clock
// doesn't reliably reflect that yet), which re-seeks the freshly rebuilt
// timeline to the wrong frame and leaves the element (and its overlay)
// rendered at a stale/unrelated position.
const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0;
// Track whether the MotionPath async path was taken. When it is, the script
// executes inside pluginScript.onload — after applySoftReload has already
// returned. We optimistically return true because the script WILL execute
// once the plugin loads; the alternative (returning false) would trigger a
// full iframe reload that destroys the very WebGL context we're preserving.
let deferredToAsync = false;
// Authored-opacity resolution for the restore loop below. Three-state:
// "0.98" — the element's authored inline opacity
// "" — resolved, and the element has NO authored inline opacity
// null — unknown (no authored HTML supplied, element not found in it,
// and no runtime parse-time stamp)
// The just-written file (`authoredHtml`) is the current truth; the runtime's
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
let authoredDoc: Document | null | undefined;
const findAuthoredSource = (el: HTMLElement): Element | null => {
if (authoredDoc === undefined) {
try {
authoredDoc = authoredHtml
? new DOMParser().parseFromString(authoredHtml, "text/html")
: null;
} catch {
authoredDoc = null;
}
}
if (!authoredDoc) return null;
const hfId = el.getAttribute("data-hf-id");
if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
return el.id ? authoredDoc.getElementById(el.id) : null;
};
const readAuthoredOpacity = (el: HTMLElement): string | null => {
const source = findAuthoredSource(el);
if (source instanceof HTMLElement) return source.style.opacity;
return readStampedAuthoredOpacity(el);
};
// fallow-ignore-next-line complexity
const doReload = () => {
const timelines = win.__timelines;
const allTargets: Element[] = [];
// Kill ONLY the target composition's timeline(s) — leaving every other
// composition's timeline (and its children on the global timeline) intact.
if (timelines) {
for (const key of targetKeys) {
const tl = timelines[key] as
| {
kill?: () => void;
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
}
| undefined;
if (!tl) continue;
if (tl.getChildren) {
try {
for (const child of tl.getChildren(true)) {
if (typeof child.targets === "function") {
for (const t of child.targets()) allTargets.push(t);
}
}
} catch {}
}
try {
tl.kill?.();
} catch {}
delete timelines[key];
}
}
// Also reset elements carrying a GSAP-applied inline `transform` that the
// timeline-children sweep above missed — a dragged element whose position
// was a standalone `gsap.set` (never a timeline child), or one whose
// keyframes were just removed (no longer in any timeline). Their last
// `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
// and the sweep above can't see it, so the element renders offset from its
// source position (matching the overlay) until a full reload. The clear
// below runs BEFORE the re-run, which re-applies the transform for any
// element the new script still animates.
const seenTargets = new Set<Element>(allTargets);
for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
// Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
// never strip an authored, non-GSAP inline transform.
if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
seenTargets.add(el);
allTargets.push(el);
}
}
// Reset GSAP's internal transform cache so from() tweens don't read stale
// end values. `clearProps: "all"` is needed to flush the cache, but it also
// nukes the element's CSS base (position, width, height, etc.) from the
// HTML `style=""` attribute. Save → clear → restore → strip `transform`.
if (allTargets.length > 0 && win.gsap?.set) {
const saved: Array<[HTMLElement, string]> = [];
for (const el of allTargets) {
// Iframe-realm node: instanceof HTMLElement fails across realms, and
// gsap targets() only yields elements here — style access is duck-typed.
const styled = el as HTMLElement;
if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
}
try {
win.gsap.set(allTargets, { clearProps: "all" });
} catch {}
for (const [el, css] of saved) {
const s = el.style;
s.cssText = css;
s.removeProperty("transform");
// The restored cssText carries RUNTIME opacity, not authored opacity:
// a mid-flight tween's interpolated value, or the color-grading hide
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
// against it — a from() captures it as its END, a to() as its START —
// turning the transient into the tween's permanent bound (dimmed or
// invisible elements). Put the AUTHORED inline opacity back; the seek
// below re-renders the correct animated value either way.
const authored = readAuthoredOpacity(el);
if (authored !== null) {
applyAuthoredInlineOpacity(s, authored);
} else if (
el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) &&
s.getPropertyValue("opacity") === "0" &&
s.getPropertyPriority("opacity") === "important"
) {
// Authored value unknown, but this is definitely the grading hide —
// never let a from() capture 0; fall back to the CSS cascade.
s.removeProperty("opacity");
}
}
}
for (const script of staleScripts) script.remove();
const executeScript = () => {
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
win.gsap.registerPlugin(win.MotionPathPlugin);
}
const s = doc.createElement("script");
s.textContent = `(function(){${scriptText}\n})();`;
doc.body.appendChild(s);
// Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
// (see init.ts) renders the freshly-created timeline at whatever the
// runtime's internal scrub position already is, not at whatever we pass
// here afterward — a redundant seek() call after rebind can be a GSAP
// no-op if the timeline already reports being at that time internally.
win.__player?.seek?.(currentTime);
win.__hfForceTimelineRebind?.();
win.__hfStudioManualEditsApply?.();
};
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
deferredToAsync = true;
// A prior soft reload is already fetching the plugin — don't queue a second
// <script> (it re-flashes the iframe). Defer THIS script's execution until
// the in-flight load settles via a one-shot poll. The bootstrap guard is
// the single source of truth for "plugin fetch in progress".
if (win.__hfMotionPathPluginLoading) {
const started = Date.now();
const poll = win.setInterval(() => {
if (win.MotionPathPlugin) {
win.clearInterval(poll);
executeScript();
} else if (!win.__hfMotionPathPluginLoading || Date.now() - started > 10000) {
// The in-flight load finished without registering the plugin (errored)
// or we timed out — recover with a full reload instead of running a
// script that references a missing plugin.
win.clearInterval(poll);
onAsyncFailure?.();
}
}, 50);
return;
}
win.__hfMotionPathPluginLoading = true;
const pluginScript = doc.createElement("script");
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
pluginScript.onload = () => {
win.__hfMotionPathPluginLoading = false;
executeScript();
};
pluginScript.onerror = () => {
// The plugin failed to load. Running executeScript() now would leave the
// iframe with a motionPath tween referencing a missing plugin while the
// caller already thinks the soft reload succeeded. Signal failure so the
// caller can full-reload (which fetches the plugin fresh) instead.
win.__hfMotionPathPluginLoading = false;
onAsyncFailure?.();
};
doc.head.appendChild(pluginScript);
return;
}
executeScript();
};
try {
if (win.__hfSuppressSceneMutations) {
win.__hfSuppressSceneMutations(doReload);
} else {
doReload();
}
// When MotionPath needs async loading, the script hasn't executed yet —
// skip the __timelines check and report success optimistically (the script
// WILL run on plugin load; onAsyncFailure covers the CDN-error case).
if (deferredToAsync) return "applied";
// The re-run executed. If the target keys read back, we're done; otherwise
// it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
// as "verify-failed" so callers know NOT to escalate.
return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
} catch {
// The synchronous re-run threw — the preview is now genuinely broken (target
// timeline killed, script not re-registered). Escalate to a full reload.
return "cannot-soft-reload";
}
}
@@ -0,0 +1,90 @@
// keep-in-sync-with: packages/core/src/utils/htmlAttrSafety.ts
const ALLOWED_HTML_ATTRS = new Set([
"id",
"class",
"style",
"title",
"name",
"for",
"type",
"lang",
"dir",
"translate",
"hidden",
"tabindex",
"draggable",
"contenteditable",
"role",
"slot",
"href",
"target",
"rel",
"src",
"srcset",
"sizes",
"alt",
"poster",
"loading",
"decoding",
"crossorigin",
"preload",
"autoplay",
"loop",
"muted",
"controls",
"playsinline",
"width",
"height",
"colspan",
"rowspan",
"scope",
"placeholder",
"value",
"min",
"max",
"step",
"pattern",
"required",
"disabled",
"readonly",
"checked",
"selected",
"multiple",
"accept",
"maxlength",
"minlength",
"rows",
"cols",
"wrap",
]);
const URI_BEARING_ATTRS = new Set([
"src",
"href",
"action",
"formaction",
"poster",
"srcset",
"xlink:href",
]);
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
export function isAllowedHtmlAttribute(name: string): boolean {
const lower = name.toLowerCase();
if (lower.startsWith("on")) return false;
if (ALLOWED_HTML_ATTRS.has(lower)) return true;
if (lower.startsWith("data-")) return true;
if (lower.startsWith("aria-")) return true;
return false;
}
export function isSafeAttributeValue(name: string, value: string): boolean {
if (URI_BEARING_ATTRS.has(name.toLowerCase())) {
const trimmed = value.trim();
if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
if (DANGEROUS_DATA_URI.test(trimmed)) return false;
}
return true;
}
+164
View File
@@ -0,0 +1,164 @@
/**
* HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source.
*/
/**
* Parse a CSS inline style string into a key-value map.
* e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" →
* { opacity: "0.5", transform: "matrix(1,0,0,1,0,0)" }
*/
export function parseStyleString(style: string): Record<string, string> {
const result: Record<string, string> = {};
for (const decl of style.split(";")) {
const colonIdx = decl.indexOf(":");
if (colonIdx < 0) continue;
const key = decl.slice(0, colonIdx).trim();
const value = decl.slice(colonIdx + 1).trim();
if (key && value) result[key] = value;
}
return result;
}
/**
* Merge `newStyles` into an opening tag string's `style` attribute.
* - New values win over existing ones.
* - If no `style` attribute is present, one is added before the closing `>`.
*/
export function mergeStyleIntoTag(tag: string, newStyles: string): string {
if (!newStyles.trim()) return tag;
const incoming = parseStyleString(newStyles);
// Match style="..." or style='...' — handle multi-line attrs via dotall-like trick
const styleAttrRe = /style=(["'])([\s\S]*?)\1/;
const match = tag.match(styleAttrRe);
if (match) {
const quote = match[1];
const existing = parseStyleString(match[2]);
const merged = { ...existing, ...incoming };
const serialized = Object.entries(merged)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
return tag.replace(styleAttrRe, `style=${quote}${serialized}${quote}`);
}
// No style attribute — insert one before the closing `>`
const serialized = Object.entries(incoming)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
// Handle self-closing tags (`/>`) and regular closing (`>`)
return tag.replace(/(\/?>)$/, ` style="${serialized}"$1`);
}
/**
* Find the full element block (opening tag through closing tag) in the source.
* Uses quote-aware scanning to handle attributes containing >.
* Uses depth counting to handle nested same-name tags.
*/
export function findElementBlock(
html: string,
elementId: string,
): {
start: number;
end: number;
openTag: string;
tagName: string;
indent: string;
innerContent: string;
isSelfClosing: boolean;
} | null {
let idIdx = html.indexOf(`id="${elementId}"`);
if (idIdx < 0) idIdx = html.indexOf(`id='${elementId}'`);
if (idIdx < 0) return null;
// Walk backward to find < and capture indent
let tagStart = idIdx;
while (tagStart > 0 && html[tagStart] !== "<") tagStart--;
let indentStart = tagStart;
while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--;
const indent = html.slice(indentStart, tagStart);
// Walk forward from id to find the closing > of the opening tag
let tagEnd = idIdx;
let inQuote: string | null = null;
while (tagEnd < html.length) {
const ch = html[tagEnd];
if (inQuote) {
if (ch === inQuote) inQuote = null;
} else {
if (ch === '"' || ch === "'") inQuote = ch;
if (ch === ">") {
tagEnd++;
break;
}
}
tagEnd++;
}
const openTag = html.slice(tagStart, tagEnd);
const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i);
if (!tagNameMatch) return null;
const tagName = tagNameMatch[1];
const isSelfClosing =
openTag.trimEnd().endsWith("/>") ||
["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase());
if (isSelfClosing) {
return {
start: tagStart,
end: tagStart + openTag.length,
openTag,
tagName,
indent: /^[\t ]*$/.test(indent) ? indent : "",
innerContent: "",
isSelfClosing: true,
};
}
// Find matching closing tag using depth counting
const closeTag = `</${tagName.toLowerCase()}>`;
const openPattern = `<${tagName.toLowerCase()}`;
let depth = 0;
let pos = tagStart;
const lower = html.toLowerCase();
while (pos < html.length) {
if (lower.startsWith("<!--", pos)) {
const commentEnd = lower.indexOf("-->", pos + 4);
pos = commentEnd < 0 ? html.length : commentEnd + 3;
continue;
}
if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) {
depth++;
pos += openPattern.length;
continue;
}
if (lower.startsWith(closeTag, pos)) {
depth--;
if (depth === 0) {
const end = pos + closeTag.length;
const innerContent = html.slice(tagStart + openTag.length, pos);
return {
start: tagStart,
end,
openTag,
tagName,
indent: /^[\t ]*$/.test(indent) ? indent : "",
innerContent,
isSelfClosing: false,
};
}
pos += closeTag.length;
continue;
}
pos++;
}
return null;
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { selectedKeyframePercentagesForElement } from "./keyframeSelection";
describe("selectedKeyframePercentagesForElement", () => {
it("returns the percentages of keyframes on the active element", () => {
const selected = new Set(["comp#a:25", "comp#a:75"]);
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25, 75]);
});
it("drops keyframes that belong to other elements", () => {
// The bug: a stale shift-selection on `comp#b` would otherwise have its
// percentages applied to the now-active `comp#a`, deleting the wrong keyframes.
const selected = new Set(["comp#a:25", "comp#b:50", "comp#b:80"]);
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25]);
});
it("returns nothing when no key belongs to the active element", () => {
const selected = new Set(["comp#b:50"]);
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]);
});
it("returns nothing when there is no active element", () => {
const selected = new Set(["comp#a:25"]);
expect(selectedKeyframePercentagesForElement(selected, null)).toEqual([]);
});
it("returns nothing for an empty selection", () => {
expect(selectedKeyframePercentagesForElement(new Set(), "comp#a")).toEqual([]);
});
it("splits on the final colon so element ids containing ':' still match", () => {
const selected = new Set(["a:b:40"]);
expect(selectedKeyframePercentagesForElement(selected, "a:b")).toEqual([40]);
});
it("skips keys without a percentage separator", () => {
const selected = new Set(["comp#a"]);
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]);
});
it("skips keys whose percentage is not a finite number", () => {
const selected = new Set(["comp#a:abc", "comp#a:NaN", "comp#a:30"]);
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([30]);
});
});
@@ -0,0 +1,29 @@
/**
* Resolves which keyframe percentages a bulk operation should act on.
*
* `selectedKeyframes` holds `"<elementId>:<percentage>"` keys and can contain
* keyframes from more than one element — e.g. a shift-selection made before the
* active element changed (via a keyframe click, a clip click, the layers panel,
* or the keyframe context menu). A bulk delete only targets the active
* element's animation, so keys belonging to other elements must be dropped;
* otherwise their percentages get applied to the active element and remove
* keyframes the user never selected on it.
*
* The element id is everything before the final `:` so element ids that happen
* to contain `:` are handled correctly.
*/
export function selectedKeyframePercentagesForElement(
selectedKeyframes: ReadonlySet<string>,
activeElementId: string | null,
): number[] {
if (!activeElementId) return [];
const percentages: number[] = [];
for (const key of selectedKeyframes) {
const separator = key.lastIndexOf(":");
if (separator < 0) continue;
if (key.slice(0, separator) !== activeElementId) continue;
const percentage = Number(key.slice(separator + 1));
if (Number.isFinite(percentage)) percentages.push(percentage);
}
return percentages;
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { rectsOverlap } from "./marqueeGeometry";
describe("rectsOverlap", () => {
it("overlapping rects", () => {
expect(
rectsOverlap(
{ left: 0, top: 0, width: 10, height: 10 },
{ left: 5, top: 5, width: 10, height: 10 },
),
).toBe(true);
});
it("non-overlapping rects", () => {
expect(
rectsOverlap(
{ left: 0, top: 0, width: 10, height: 10 },
{ left: 20, top: 20, width: 10, height: 10 },
),
).toBe(false);
});
it("touching edges do not overlap", () => {
expect(
rectsOverlap(
{ left: 0, top: 0, width: 10, height: 10 },
{ left: 10, top: 0, width: 10, height: 10 },
),
).toBe(false);
});
it("one rect fully inside another overlaps", () => {
expect(
rectsOverlap(
{ left: 0, top: 0, width: 100, height: 100 },
{ left: 25, top: 25, width: 10, height: 10 },
),
).toBe(true);
});
});
@@ -0,0 +1,15 @@
export interface Rect {
left: number;
top: number;
width: number;
height: number;
}
export function rectsOverlap(a: Rect, b: Rect): boolean {
return (
a.left < b.left + b.width &&
a.left + a.width > b.left &&
a.top < b.top + b.height &&
a.top + a.height > b.top
);
}
+10
View File
@@ -0,0 +1,10 @@
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
export const LUT_EXT = /\.cube$/i;
export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
export function isMediaFile(path: string): boolean {
return MEDIA_EXT.test(path);
}
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from "vitest";
import { executeOptimistic } from "./optimisticUpdate";
describe("executeOptimistic", () => {
it("calls apply then persist on success, never rollback", async () => {
const apply = vi.fn(() => "snapshot");
const persist = vi.fn(() => Promise.resolve());
const rollback = vi.fn();
await executeOptimistic({ apply, persist, rollback });
expect(apply).toHaveBeenCalledOnce();
expect(persist).toHaveBeenCalledOnce();
expect(rollback).not.toHaveBeenCalled();
});
it("calls rollback with snapshot on persist failure", async () => {
const apply = vi.fn(() => ({ prev: "data" }));
const persist = vi.fn(() => Promise.reject(new Error("network")));
const rollback = vi.fn();
await executeOptimistic({ apply, persist, rollback });
expect(apply).toHaveBeenCalledOnce();
expect(persist).toHaveBeenCalledOnce();
expect(rollback).toHaveBeenCalledWith({ prev: "data" });
});
it("preserves complex snapshot objects through rollback", async () => {
const snapshot = {
format: "percentage",
keyframes: [{ percentage: 0, properties: { opacity: 0 } }],
};
const apply = vi.fn(() => structuredClone(snapshot));
const persist = vi.fn(() => Promise.reject(new Error("500")));
const rollback = vi.fn();
await executeOptimistic({ apply, persist, rollback });
expect(rollback).toHaveBeenCalledOnce();
expect(rollback.mock.calls[0][0]).toEqual(snapshot);
});
it("handles undefined snapshot for rollback", async () => {
const apply = vi.fn(() => undefined);
const persist = vi.fn(() => Promise.reject(new Error("timeout")));
const rollback = vi.fn();
await executeOptimistic({ apply, persist, rollback });
expect(rollback).toHaveBeenCalledWith(undefined);
});
});
@@ -0,0 +1,17 @@
export interface OptimisticUpdateOptions<TSnapshot> {
/** Apply the change to local state immediately. Return a snapshot for rollback. */
apply: () => TSnapshot;
/** Persist the change to the server. */
persist: () => Promise<unknown>;
/** Revert local state using the snapshot if persist fails. */
rollback: (snapshot: TSnapshot) => void;
}
export async function executeOptimistic<T>(options: OptimisticUpdateOptions<T>): Promise<void> {
const snapshot = options.apply();
try {
await options.persist();
} catch {
options.rollback(snapshot);
}
}
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import { buildFrameCaptureUrl } from "./frameCapture";
import {
buildProjectApiPath,
buildProjectHash,
encodeProjectId,
parseProjectHashRoute,
parseProjectIdFromHash,
} from "./projectRouting";
describe("project routing utilities", () => {
it("decodes project ids from hash routes before building capture URLs", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
const projectId = parseProjectIdFromHash("#project/Notion%20Showcase");
expect(projectId).toBe("Notion Showcase");
expect(
buildFrameCaptureUrl({
projectId: projectId ?? "",
compositionPath: null,
currentTime: 1.809,
origin: "http://localhost:3002",
}),
).toBe(
"http://localhost:3002/api/projects/Notion%20Showcase/thumbnail/index.html?t=1.809&format=png&v=1777636800000",
);
vi.useRealTimers();
});
it("accepts legacy raw-space hash routes", () => {
expect(parseProjectIdFromHash("#project/Notion Showcase")).toBe("Notion Showcase");
});
it("decodes reserved characters when the hash route is encoded", () => {
expect(parseProjectIdFromHash("#project/Launch%20%231%3F%20v2")).toBe("Launch #1? v2");
});
it("does not throw on malformed percent escapes in hash routes", () => {
expect(parseProjectIdFromHash("#project/Broken%ZZName")).toBe("Broken%ZZName");
});
it("ignores non-project hash routes", () => {
expect(parseProjectIdFromHash("")).toBeNull();
expect(parseProjectIdFromHash("#settings")).toBeNull();
expect(parseProjectIdFromHash("#project/")).toBeNull();
expect(parseProjectIdFromHash("#project/foo/bar")).toBeNull();
});
it("encodes project ids when writing hash routes", () => {
expect(buildProjectHash("Notion Showcase")).toBe("#project/Notion%20Showcase");
expect(buildProjectHash("Notion%20Showcase")).toBe("#project/Notion%2520Showcase");
expect(buildProjectHash("Launch #1? v2")).toBe("#project/Launch%20%231%3F%20v2");
});
it("round-trips unicode project ids through hash routes", () => {
const hash = buildProjectHash("Mañana demo");
expect(hash).toBe("#project/Ma%C3%B1ana%20demo");
expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
});
it("parses project hash routes with query params", () => {
const route = parseProjectHashRoute("#project/Notion%20Showcase?tab=design&t=4.2");
expect(route?.projectId).toBe("Notion Showcase");
expect(route?.params.get("tab")).toBe("design");
expect(route?.params.get("t")).toBe("4.2");
});
it("builds hash routes with query params", () => {
expect(buildProjectHash("Notion Showcase", { tab: "design", t: "4.2" })).toBe(
"#project/Notion%20Showcase?tab=design&t=4.2",
);
});
it("encodes project ids as one API path segment", () => {
expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
expect(encodeProjectId("Launch #1? v2")).toBe("Launch%20%231%3F%20v2");
});
it("builds API paths without double encoding decoded project ids", () => {
expect(buildProjectApiPath("Notion Showcase", "/thumbnail/index.html")).toBe(
"/api/projects/Notion%20Showcase/thumbnail/index.html",
);
});
it("keeps literal percent signs safe in API paths", () => {
expect(buildProjectApiPath("Percent%20Name", "/preview")).toBe(
"/api/projects/Percent%2520Name/preview",
);
});
it("keeps unicode project ids safe in API paths", () => {
expect(buildProjectApiPath("Mañana demo", "/preview")).toBe(
"/api/projects/Ma%C3%B1ana%20demo/preview",
);
});
});
@@ -0,0 +1,64 @@
const PROJECT_HASH_PREFIX = "#project/";
export interface ProjectHashRoute {
projectId: string;
params: URLSearchParams;
}
function decodeHashProjectId(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function normalizeHashParams(
params?: URLSearchParams | Record<string, string | null | undefined>,
): URLSearchParams {
if (!params) return new URLSearchParams();
if (params instanceof URLSearchParams) return params;
const next = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (!key || value == null || value === "") continue;
next.set(key, value);
}
return next;
}
export function encodeProjectId(projectId: string): string {
return encodeURIComponent(projectId);
}
export function buildProjectHash(
projectId: string,
params?: URLSearchParams | Record<string, string | null | undefined>,
): string {
const search = normalizeHashParams(params).toString();
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}${search ? `?${search}` : ""}`;
}
export function parseProjectHashRoute(hash: string): ProjectHashRoute | null {
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
const route = hash.slice(PROJECT_HASH_PREFIX.length);
const queryIndex = route.indexOf("?");
const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : "";
return {
projectId: decodeHashProjectId(encodedProjectId),
params: new URLSearchParams(rawParams),
};
}
export function parseProjectIdFromHash(hash: string): string | null {
return parseProjectHashRoute(hash)?.projectId ?? null;
}
export function buildProjectApiPath(projectId: string, suffix = ""): string {
const normalizedSuffix = suffix && !suffix.startsWith("/") ? `/${suffix}` : suffix;
return `/api/projects/${encodeProjectId(projectId)}${normalizedSuffix}`;
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Ramer-Douglas-Peucker simplification for time-series data.
*
* Used to reduce gesture recording samples into a minimal set of keyframes
* that approximate the original curve within a configurable tolerance.
*/
// ---------------------------------------------------------------------------
// 1D time-series simplification
// ---------------------------------------------------------------------------
/**
* Perpendicular distance from point (t, v) to the line segment between
* (t1, v1) and (t2, v2). For 1D time-series this reduces to the vertical
* distance from the point to the interpolated value on the line.
*/
function perpendicularDistance(
t: number,
v: number,
t1: number,
v1: number,
t2: number,
v2: number,
): number {
// Degenerate case: start and end share the same time
if (t2 === t1) return Math.abs(v - v1);
const interpolated = v1 + ((v2 - v1) * (t - t1)) / (t2 - t1);
return Math.abs(v - interpolated);
}
/**
* Standard Ramer-Douglas-Peucker on 1D time-series data.
*
* Each point is treated as (time, value) in 2D space. Returns the minimal
* subset of input points that approximates the curve within `epsilon`.
*
* - `epsilon = 0` returns all points (no simplification).
* - A large `epsilon` returns just the first and last points.
* - Empty or single-point input is returned unchanged.
*/
function simplifyTimeSeries(
points: Array<{ time: number; value: number }>,
epsilon: number,
): Array<{ time: number; value: number }> {
if (points.length <= 2) return points;
if (epsilon <= 0) return points;
const first = points[0];
const last = points[points.length - 1];
let maxDist = 0;
let maxIndex = 0;
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(
points[i].time,
points[i].value,
first.time,
first.value,
last.time,
last.value,
);
if (d > maxDist) {
maxDist = d;
maxIndex = i;
}
}
if (maxDist > epsilon) {
const left = simplifyTimeSeries(points.slice(0, maxIndex + 1), epsilon);
const right = simplifyTimeSeries(points.slice(maxIndex), epsilon);
// left includes maxIndex, right starts with maxIndex — drop the duplicate
return left.slice(0, -1).concat(right);
}
return [first, last];
}
// ---------------------------------------------------------------------------
// Multi-property gesture simplification
// ---------------------------------------------------------------------------
/**
* Simplify gesture recording samples into percentage-keyed keyframes.
*
* Runs `simplifyTimeSeries` independently per property across all samples,
* then merges the retained time points into a single Map keyed by percentage
* of `totalDuration` (0100, rounded to 1 decimal).
*
* Independent per-property simplification means that complex motion on one
* property (e.g. `x`) does not force extra keyframes on a simpler property
* (e.g. `opacity`).
*
* At each retained percentage the output contains all properties interpolated
* at that time — not just the property that caused the time point to survive.
*/
export function simplifyGestureSamples(
samples: Array<{ time: number; properties: Record<string, number> }>,
totalDuration: number,
epsilon: number | ((key: string) => number),
): Map<number, Record<string, number>> {
if (samples.length === 0) return new Map();
if (totalDuration <= 0) return new Map();
// Collect all property keys present across samples
const propertyKeys = new Set<string>();
for (const s of samples) {
for (const key of Object.keys(s.properties)) {
propertyKeys.add(key);
}
}
// Run RDP independently per property and collect surviving times
const survivingTimes = new Set<number>();
for (const key of propertyKeys) {
const series: Array<{ time: number; value: number }> = [];
for (const s of samples) {
if (key in s.properties) {
series.push({ time: s.time, value: s.properties[key] });
}
}
const keyEpsilon = typeof epsilon === "function" ? epsilon(key) : epsilon;
const simplified = simplifyTimeSeries(series, keyEpsilon);
for (const pt of simplified) {
survivingTimes.add(pt.time);
}
}
// Sort surviving times so we can iterate in order
const sortedTimes = Array.from(survivingTimes).sort((a, b) => a - b);
// For each surviving time, interpolate all properties and store by percentage
const result = new Map<number, Record<string, number>>();
for (const t of sortedTimes) {
const pct = Math.round((t / totalDuration) * 1000) / 10; // 1 decimal
const props: Record<string, number> = {};
for (const key of propertyKeys) {
props[key] = interpolatePropertyAtTime(samples, key, t);
}
result.set(pct, props);
}
return result;
}
/**
* Linearly interpolate a single property value at the given time from the
* samples array. Assumes samples are sorted by time.
*/
function interpolatePropertyAtTime(
samples: Array<{ time: number; properties: Record<string, number> }>,
key: string,
t: number,
): number {
// Find bracketing samples that contain this property
let before: { time: number; value: number } | undefined;
let after: { time: number; value: number } | undefined;
for (const s of samples) {
if (!(key in s.properties)) continue;
const v = s.properties[key];
if (s.time <= t) {
before = { time: s.time, value: v };
}
if (s.time >= t && after === undefined) {
after = { time: s.time, value: v };
}
}
// Exact match or only one side available
if (before && before.time === t) return before.value;
if (after && after.time === t) return after.value;
if (!before) return after!.value;
if (!after) return before.value;
// Linear interpolation
const ratio = (t - before.time) / (after.time - before.time);
return before.value + (after.value - before.value) * ratio;
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { extendRootDurationInSource } from "./rootDuration";
describe("extendRootDurationInSource", () => {
it("extends data-duration when the new end is bigger than the root duration", () => {
const source = [
`<div data-composition-id="main" data-duration="4">`,
` <section id="clip" data-start="2" data-duration="3"></section>`,
`</div>`,
].join("\n");
expect(extendRootDurationInSource(source, 5.25)).toContain(
`data-composition-id="main" data-duration="5.25"`,
);
});
it("does nothing when the new end is smaller than or equal to the root duration", () => {
const source = `<div data-composition-id="main" data-duration="6"></div>`;
expect(extendRootDurationInSource(source, 5)).toBe(source);
expect(extendRootDurationInSource(source, 6)).toBe(source);
});
it("leaves non-root data-duration attributes untouched by the extension", () => {
const source = [
`<div data-duration="3"></div>`,
`<div data-composition-id="main" data-duration="4"></div>`,
].join("\n");
const patched = extendRootDurationInSource(source, 7);
expect(patched).toContain(`<div data-duration="3"></div>`);
expect(patched).toContain(`<div data-composition-id="main" data-duration="7"></div>`);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
export function extendRootDurationInSource(source: string, newEnd: number): string {
const rootDurMatch = source.match(
/(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
);
if (rootDurMatch) {
const rootDur = parseFloat(rootDurMatch[2]!);
if (newEnd > rootDur) {
return source.replace(
rootDurMatch[0],
`${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
);
}
}
return source;
}
+9
View File
@@ -0,0 +1,9 @@
/** Round to 3 decimal places (millisecond precision for GSAP values). */
export function roundTo3(val: number): number {
return Math.round(val * 1000) / 1000;
}
/** Round to 2 decimal places (centisecond precision for timeline values). */
export function roundToCenti(val: number): number {
return Math.round(val * 100) / 100;
}
+20
View File
@@ -0,0 +1,20 @@
// Best-effort access to Web Storage. Reading the `localStorage` /
// `sessionStorage` globals can throw (SSR, storage disabled, sandboxed or
// partitioned browsing contexts), so callers get `null` instead of an
// exception — telemetry must never break Studio.
export function safeLocalStorage(): Storage | null {
try {
return typeof localStorage === "undefined" ? null : localStorage;
} catch {
return null;
}
}
export function safeSessionStorage(): Storage | null {
try {
return typeof sessionStorage === "undefined" ? null : sessionStorage;
} catch {
return null;
}
}
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
// Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
// persist chokepoint must return false so the caller takes the legacy server
// path — even when a valid SDK session exists (one always does, for
// shadow/selection). This is the contract the prod flag-flip rests on; a future
// refactor of the gate guards that silently re-enables cutover on flag-off
// turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: false,
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
}));
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { sdkTimingPersist, sdkGsapTweenPersist, sdkDeletePersist } from "./sdkCutover";
const makeSession = () =>
({
getElement: () => ({ inlineStyles: {} }),
serialize: () => "<html></html>",
batch: (fn: () => void) => fn(),
setTiming: vi.fn(),
dispatch: vi.fn(),
}) as never;
const makeDeps = () =>
({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
}) as never;
describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist returns false", () => {
it("sdkTimingPersist falls back without writing", async () => {
const deps = makeDeps();
expect(await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps)).toBe(
false,
);
expect(
(deps as unknown as { writeProjectFile: ReturnType<typeof vi.fn> }).writeProjectFile,
).not.toHaveBeenCalled();
});
it("sdkGsapTweenPersist (shared GSAP-op chokepoint) falls back", async () => {
expect(
await sdkGsapTweenPersist(
"/c.html",
{ kind: "remove", animationId: "a" },
makeSession(),
makeDeps(),
),
).toBe(false);
});
it("sdkDeletePersist falls back", async () => {
expect(
await sdkDeletePersist("hf-a", "<html></html>", "/c.html", makeSession(), makeDeps()),
).toBe(false);
});
});
@@ -0,0 +1,890 @@
import { describe, expect, it, vi } from "vitest";
import {
shouldUseSdkCutover,
sdkCutoverPersist,
sdkDeletePersist,
sdkTimingPersist,
sdkGsapTweenPersist,
sdkGsapKeyframePersist,
} from "./sdkCutover";
import { openComposition } from "@hyperframes/sdk";
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
import type { PatchOperation } from "./sourcePatcher";
import type { MutableRefObject } from "react";
vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: true,
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
}));
vi.mock("./studioTelemetry", () => ({
trackStudioEvent: vi.fn(),
}));
const styleOp = (property: string, value: string): PatchOperation => ({
type: "inline-style",
property,
value,
});
const textOp = (value: string): PatchOperation => ({
type: "text-content",
property: "text",
value,
});
const attrOp = (property: string, value: string): PatchOperation => ({
type: "attribute",
property,
value,
});
const htmlAttrOp = (property: string, value: string): PatchOperation => ({
type: "html-attribute",
property,
value,
});
describe("shouldUseSdkCutover", () => {
it("returns false when flag disabled", () => {
expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
});
it("returns false when no session", () => {
expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
});
it("returns false when no hfId", () => {
expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
});
it("returns false when ops empty", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
});
it("returns true for inline-style ops", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
});
it("returns true for text-content ops", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [textOp("hello")])).toBe(true);
});
it("returns true for attribute ops", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-x", "10")])).toBe(true);
});
it("returns true for html-attribute ops", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true);
});
it("returns false for an attribute op that maps to a reserved data-* name", () => {
// {type:'attribute', property:'end'} → 'data-end', which the SDK's
// validateSetAttribute rejects. Decline the batch so it takes the server
// path cleanly instead of throwing inside dispatch and falling back per op.
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("end", "2")])).toBe(false);
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-start", "1")])).toBe(false);
});
it("declines a case-variant reserved attribute (SDK lowercases before checking)", () => {
// attribute op "END" → "data-END" → lower → "data-end" (reserved).
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("END", "2")])).toBe(false);
});
it("declines an html-attribute op whose raw name is reserved", () => {
// html-attribute ops aren't data-prefixed, so a raw reserved name must still
// be caught (the SDK throws on it just the same).
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("data-end", "3")])).toBe(false);
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(false);
});
it("declines html-attribute ops with event handler names", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
false,
);
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onload", "fetch()")])).toBe(
false,
);
});
it("declines html-attribute ops with disallowed attribute names", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("formaction", "/x")])).toBe(false);
});
it("declines html-attribute ops with dangerous URI schemes", () => {
expect(
shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("href", "javascript:alert(1)")]),
).toBe(false);
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("src", "vbscript:run")])).toBe(
false,
);
});
it("declines html-attribute ops with dangerous data URIs", () => {
expect(
shouldUseSdkCutover(true, true, "hf-abc", [
htmlAttrOp("href", "data:text/html,<script>alert(1)</script>"),
]),
).toBe(false);
});
it("returns true when ops mix all supported types", () => {
expect(
shouldUseSdkCutover(true, true, "hf-abc", [
styleOp("color", "red"),
textOp("hello"),
attrOp("x", "1"),
htmlAttrOp("class", "foo"),
]),
).toBe(true);
});
});
describe("sdkCutoverPersist", () => {
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
const makeDeps = (overrides: Partial<Parameters<typeof sdkCutoverPersist>[5]> = {}) => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
...overrides,
});
const makeSession = (hasEl = true) =>
({
getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null),
dispatch: vi.fn(),
// Distinct before/after so the no-op guard (after === before → fall back)
// treats this as a real change; "after" matches the write assertions.
serialize: vi
.fn()
.mockReturnValueOnce("<html>before</html>")
.mockReturnValue("<html></html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkCutoverPersist>[4];
it("returns false when session is null", async () => {
const deps = makeDeps();
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red")],
"before",
"/path.html",
null,
deps,
);
expect(result).toBe(false);
});
it("returns false when element not found in session", async () => {
const deps = makeDeps();
const session = makeSession(false);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red")],
"before",
"/path.html",
session,
deps,
);
expect(result).toBe(false);
});
it("dispatches setStyle for inline-style ops", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red"), styleOp("opacity", "0.5")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "setStyle",
target: "hf-abc",
styles: { color: "red", opacity: "0.5" },
});
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
expect(deps.reloadPreview).toHaveBeenCalled();
});
it("dispatches setText for text-content op", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[textOp("Hello world")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "setText",
target: "hf-abc",
value: "Hello world",
});
});
it.each([
{ name: "multi-child targets", children: [{ id: "a" }, { id: "b" }] },
{ name: "single non-html children", children: [{ id: "a", tag: "svg" }] },
])("declines text-content cutover for $name", async ({ children }) => {
const deps = makeDeps();
const session = makeSession(true);
(session!.getElement as ReturnType<typeof vi.fn>).mockReturnValue({ children });
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[textOp("Hello world")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(false);
expect(session!.dispatch).not.toHaveBeenCalled();
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
it("dispatches setAttribute for attribute op with data- prefix", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[attrOp("x", "42")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "setAttribute",
target: "hf-abc",
name: "data-x",
value: "42",
});
});
it("dispatches setAttribute for html-attribute op", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[htmlAttrOp("class", "foo bar")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "setAttribute",
target: "hf-abc",
name: "class",
value: "foo bar",
});
});
it("passes caller label to recordEdit", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, {
label: "Resize layer box",
});
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({ label: "Resize layer box" }),
);
});
it("passes caller coalesceKey to recordEdit", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, {
coalesceKey: "my-key",
});
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({ coalesceKey: "my-key" }),
);
});
it("returns false and does not throw on dispatch error", async () => {
const deps = makeDeps();
const session = makeSession(true);
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("dispatch failed");
});
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(false);
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
it("wraps all dispatches in session.batch() for atomic rollback", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
await sdkCutoverPersist(
sel,
[styleOp("color", "red"), styleOp("opacity", "0.5")],
"before",
"/comp.html",
session,
deps,
);
expect(
(session as unknown as { batch: ReturnType<typeof vi.fn> }).batch,
).toHaveBeenCalledOnce();
});
it("returns false when second dispatch throws (batch prevents partial mutation)", async () => {
// inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches.
const deps = makeDeps();
const session = makeSession(true);
let callCount = 0;
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
callCount++;
if (callCount === 2) throw new Error("2nd op failed");
});
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red"), textOp("hello")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
});
describe("sdkDeletePersist", () => {
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 = (hasEl = true) =>
({
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
removeElement: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("<html>before-snap</html>")
.mockReturnValue("<html>after</html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
it("returns false when session is null", async () => {
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
});
it("returns false when element not found in session", async () => {
const session = makeSession(false);
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe(
false,
);
});
it("calls removeElement and writes serialized content", async () => {
const deps = makeDeps();
const session = makeSession(true);
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
expect(result).toBe(true);
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
});
it("records edit history with before/after diff", async () => {
const deps = makeDeps();
const session = makeSession(true);
await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
label: "Delete element",
files: { "/comp.html": { before: "before-content", after: "<html>after</html>" } },
}),
);
});
it("calls reloadPreview on success", async () => {
const deps = makeDeps();
const session = makeSession(true);
await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
expect(deps.reloadPreview).toHaveBeenCalled();
});
it("returns false and does not write on removeElement error", async () => {
const deps = makeDeps();
const session = makeSession(true);
(session!.removeElement as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("remove failed");
});
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
});
describe("sdkTimingPersist", () => {
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 = (hasEl = true) =>
({
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-clip" } : null),
setTiming: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("<html>before</html>")
.mockReturnValue("<html>after</html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkTimingPersist>[3];
it("returns false when session is null", async () => {
expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe(
false,
);
});
it("returns false when element not found in session", async () => {
const session = makeSession(false);
expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe(
false,
);
});
it("calls setTiming with provided update and writes serialized content", async () => {
const deps = makeDeps();
const session = makeSession(true);
const result = await sdkTimingPersist(
"hf-clip",
"/comp.html",
{ start: 2, duration: 5, trackIndex: 1 },
session,
deps,
);
expect(result).toBe(true);
expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", {
start: 2,
duration: 5,
trackIndex: 1,
});
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
});
it("captures before-state before setTiming dispatch", async () => {
const deps = makeDeps();
const session = makeSession(true);
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: { "/comp.html": { before: "<html>before</html>", after: "<html>after</html>" } },
}),
);
});
it("returns false and does not write on setTiming error", async () => {
const deps = makeDeps();
const session = makeSession(true);
(session!.setTiming as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("timing error");
});
const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
// Finding #12: undo baseline must be the EXACT on-disk bytes (matching the
// style/delete paths), not a normalized SDK serialize() re-emit — otherwise
// undoing a timing edit reformats the whole file.
it("records the on-disk content (not serialize()) as the undo before when a reader is provided", async () => {
const deps = {
...makeDeps(),
readProjectFile: vi.fn().mockResolvedValue("<html>EXACT ON-DISK BYTES</html>"),
};
const session = makeSession(true);
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html");
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: {
"/comp.html": { before: "<html>EXACT ON-DISK BYTES</html>", after: "<html>after</html>" },
},
}),
);
});
it("falls back to serialize() before when the reader throws", async () => {
const deps = {
...makeDeps(),
readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")),
};
const session = makeSession(true);
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: { "/comp.html": { before: "<html>before</html>", after: "<html>after</html>" } },
}),
);
});
});
describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
const makeSession = () =>
({
getElement: vi.fn().mockReturnValue({ id: "hf-box" }),
setGsapTween: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("<html>serialized-before</html>")
.mockReturnValue("<html>after</html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
it("records the on-disk content as the undo before, not serialize()", async () => {
const deps = {
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
readProjectFile: vi.fn().mockResolvedValue("<html>on-disk gsap bytes</html>"),
};
const session = makeSession();
await sdkGsapTweenPersist(
"/comp.html",
{ kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } },
session,
deps,
);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: {
"/comp.html": { before: "<html>on-disk gsap bytes</html>", after: "<html>after</html>" },
},
}),
);
});
});
describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
it("routes the read-modify-write through the keyed serializer so same-file flushes can't interleave", async () => {
const order: string[] = [];
let writeResolve: (() => void) | null = null;
const deps = {
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
// First write blocks until we release it, so without serialization the
// second op's serialize()/dispatch would interleave ahead of it.
writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => {
order.push(`write-start:${content}`);
if (content === "<html>after-1</html>") {
return new Promise<void>((res) => {
writeResolve = () => {
order.push(`write-done:${content}`);
res();
};
});
}
order.push(`write-done:${content}`);
return Promise.resolve();
}),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
// A real per-key serializer: tasks under the same key run strictly in order.
serialize: (() => {
const inFlight = new Map<string, Promise<unknown>>();
return <T>(key: string, task: () => Promise<T>): Promise<T> => {
const prior = inFlight.get(key) ?? Promise.resolve();
const next = prior.then(task, task);
inFlight.set(key, next);
return next as Promise<T>;
};
})(),
};
let serializeCall = 0;
const session = {
getElement: vi.fn().mockReturnValue({ id: "hf-box" }),
setGsapTween: vi.fn(() => order.push("dispatch")),
serialize: vi.fn(() => {
serializeCall++;
// before-1, after-1, before-2, after-2
return `<html>${serializeCall % 2 === 1 ? "before" : "after"}-${Math.ceil(serializeCall / 2)}</html>`;
}),
batch: vi.fn((fn: () => void) => fn()),
} as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
const p1 = sdkGsapTweenPersist(
"/comp.html",
{ kind: "set", animationId: "tw-1", properties: { ease: "a" } },
session,
deps,
);
const p2 = sdkGsapTweenPersist(
"/comp.html",
{ kind: "set", animationId: "tw-1", properties: { ease: "b" } },
session,
deps,
);
// Let the first op reach its (blocked) write before releasing it.
await Promise.resolve();
await Promise.resolve();
writeResolve?.();
await Promise.all([p1, p2]);
// The second op's write must NOT start before the first op's write completes.
const firstWriteDone = order.indexOf("write-done:<html>after-1</html>");
const secondWriteStart = order.indexOf("write-start:<html>after-2</html>");
expect(firstWriteDone).toBeGreaterThanOrEqual(0);
expect(secondWriteStart).toBeGreaterThan(firstWriteDone);
});
});
describe("sdkGsapTweenPersist", () => {
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 = (opts?: { addGsapTween?: string; hasEl?: boolean }) =>
({
getElement: vi.fn().mockReturnValue(opts?.hasEl !== false ? { id: "hf-box" } : null),
addGsapTween: vi.fn().mockReturnValue(opts?.addGsapTween ?? "tw-1"),
setGsapTween: vi.fn(),
removeGsapTween: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("<html>before</html>")
.mockReturnValue("<html>after</html>"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
it("returns false when session is null", async () => {
expect(
await sdkGsapTweenPersist(
"/comp.html",
{ kind: "remove", animationId: "tw-1" },
null,
makeDeps(),
),
).toBe(false);
});
it("calls addGsapTween and writes for kind=add", async () => {
const deps = makeDeps();
const session = makeSession();
const result = await sdkGsapTweenPersist(
"/comp.html",
{
kind: "add",
target: "hf-box",
spec: { method: "to", duration: 1, properties: { opacity: 1 } },
},
session,
deps,
);
expect(result).toBe(true);
expect(session!.addGsapTween).toHaveBeenCalledWith(
"hf-box",
expect.objectContaining({ method: "to" }),
);
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
});
it("returns false for kind=add when element not found", async () => {
const deps = makeDeps();
const session = makeSession({ hasEl: false });
const result = await sdkGsapTweenPersist(
"/comp.html",
{ kind: "add", target: "hf-box", spec: { method: "to", properties: { x: 100 } } },
session,
deps,
);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
it("calls setGsapTween and writes for kind=set", async () => {
const deps = makeDeps();
const session = makeSession();
const result = await sdkGsapTweenPersist(
"/comp.html",
{ kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } },
session,
deps,
);
expect(result).toBe(true);
expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" });
expect(deps.reloadPreview).toHaveBeenCalled();
});
it("calls removeGsapTween for kind=remove", async () => {
const deps = makeDeps();
const session = makeSession();
const result = await sdkGsapTweenPersist(
"/comp.html",
{ kind: "remove", animationId: "tw-1" },
session,
deps,
);
expect(result).toBe(true);
expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1");
});
it("returns false and does not write on SDK error", async () => {
const deps = makeDeps();
const session = makeSession();
(session!.removeGsapTween as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("gsap error");
});
const result = await sdkGsapTweenPersist(
"/comp.html",
{ kind: "remove", animationId: "tw-1" },
session,
deps,
);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
});
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>"),
batch: vi.fn((fn: () => void) => fn()),
}) 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 = () => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
});
it("preserves GSAP <script> block and data-position-mode through setStyle dispatch", async () => {
const html = `<!DOCTYPE html><html><head></head><body>
<div data-hf-id="hf-layer" style="color: blue; opacity: 1"></div>
<script data-hf-gsap data-position-mode="relative">
gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
</script>
</body></html>`;
const comp = await openComposition(html, { persist: createMemoryAdapter() });
const deps = makeDeps();
const sel = { hfId: "hf-layer" } as never;
const result = await sdkCutoverPersist(
sel,
[{ type: "inline-style", property: "color", value: "red" }],
html,
"/comp.html",
comp,
deps,
);
expect(result).toBe(true);
const written = (deps.writeProjectFile as ReturnType<typeof vi.fn>).mock
.calls[0]?.[1] as string;
expect(written).toContain("data-hf-gsap");
expect(written).toContain('data-position-mode="relative"');
expect(written).toContain("gsap.timeline()");
});
});
+553
View File
@@ -0,0 +1,553 @@
import type { MutableRefObject } from "react";
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { EditHistoryKind } from "./editHistory";
import type { PatchOperation } from "./sourcePatcher";
import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
import { shouldDeclineTextCutoverForTarget, shouldUseSdkCutover } from "./sdkCutoverEligibility";
export { shouldUseSdkCutover } from "./sdkCutoverEligibility";
export interface CutoverDeps {
editHistory: {
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
};
writeProjectFile: (path: string, content: string) => Promise<void>;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
/**
* Optional post-write refresh. When provided, it REPLACES the default
* reloadPreview() — the GSAP path passes one that soft-reloads (preserving
* the playhead) and invalidates the keyframe/gsap panel cache. Receives the
* serialized document just written.
*/
refresh?: (after: string) => void;
/**
* Path of the composition the SDK session was opened for. The session models
* ONLY this file (serialize() emits the whole active composition), so any edit
* whose targetPath differs (a sub-composition file) must take the server path
* — otherwise we'd write the full active-comp serialization into that file.
*/
compositionPath?: string | null;
/**
* Optional per-key task serializer (the same `gsap-file:${file}` serializer the
* legacy `commitMutation` uses). When provided, every GSAP-op persist routes its
* read-serialize → dispatch → serialize → write through it so two concurrent
* same-file flushes can't interleave their read-modify-write and lose an edit.
* Absent (e.g. in unit tests) → ops run unserialized as before.
*/
serialize?: <T>(key: string, task: () => Promise<T>) => Promise<T>;
/**
* Optional reader for the on-disk content of targetPath. Timing/GSAP persists
* use it to capture the EXACT prior bytes as the undo-history `before`, so undo
* restores the file verbatim instead of a normalized SDK re-emit (which would
* reformat the whole file). The style/delete paths already thread originalContent
* in explicitly; this gives timing/GSAP parity without touching every call site.
* Absent → falls back to the SDK's pre-edit serialize() (the prior behavior).
*/
readProjectFile?: (path: string) => Promise<string>;
}
/**
* Capture the undo-history `before` baseline for timing/GSAP persists: the exact
* on-disk bytes when a reader is available (so undo restores them verbatim),
* falling back to the SDK's pre-edit serialization when it isn't. Never throws —
* a failed read degrades to the serialized fallback rather than aborting the edit.
*/
async function captureOnDiskBefore(
deps: CutoverDeps,
targetPath: string,
serializedFallback: string,
): Promise<string> {
if (!deps.readProjectFile) return serializedFallback;
try {
return await deps.readProjectFile(targetPath);
} catch {
return serializedFallback;
}
}
/** True when targetPath isn't the composition the SDK session models. */
function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
return deps.compositionPath != null && targetPath !== deps.compositionPath;
}
interface CutoverOptions {
label?: string;
coalesceKey?: string;
/** Skip the preview reload (mirrors the server path's skipRefresh). */
skipRefresh?: boolean;
}
// ponytail: exported for setSlideshowManifest (third caller — island write bypasses
// the SDK dispatch path since <script> nodes are not in the element tree).
// `after` is serialized once by the caller (which also did the no-op check
// against its pre-dispatch snapshot), so this never re-serializes.
export async function persistSdkSerialize(
after: string,
targetPath: string,
originalContent: string,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<void> {
deps.domEditSaveTimestampRef.current = Date.now();
// Tag this write with the exact content (by hash) so the file-change
// reload-suppression can recognize its own echo by IDENTITY, not just a 2 s
// clock — an undo write (different bytes, not registered here) then always
// reloads instead of being swallowed by the time window.
markSelfWrite(targetPath, after);
await deps.writeProjectFile(targetPath, after);
await deps.editHistory.recordEdit({
label: options?.label ?? "Edit layer",
kind: "manual",
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
files: { [targetPath]: { before: originalContent, after } },
});
if (deps.refresh) deps.refresh(after);
else if (!options?.skipRefresh) deps.reloadPreview();
}
export async function sdkCutoverPersist(
selection: DomEditSelection,
ops: PatchOperation[],
originalContent: string,
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops))
return false;
if (!sdkSession) return false;
const hfId = selection.hfId;
if (!hfId) return false;
const target = sdkSession.getElement(hfId);
if (!target) return false;
if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
if (wrongCompositionFile(deps, targetPath)) return false;
try {
const before = sdkSession.serialize();
sdkSession.batch(() => {
for (const editOp of patchOpsToSdkEditOps(hfId, ops)) {
sdkSession.dispatch(editOp);
}
});
const after = sdkSession.serialize();
if (after === before) return false;
await persistSdkSerialize(after, targetPath, originalContent, deps, options);
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", {
hfId: selection.hfId ?? null,
error: String(err),
});
return false;
}
}
export async function sdkTimingPersist(
hfId: string,
targetPath: string,
timingUpdate: { start?: number; duration?: number; trackIndex?: number },
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
// Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
// the SDK can't resolve a target the server timing path is addressing.
const timingSrc = deps.readProjectFile;
void recordResolverParity(
sdkSession,
hfId,
"setTiming",
timingSrc ? () => timingSrc(targetPath) : undefined,
);
// Dark-launch gate: without this, timing cutover runs whenever an SDK session
// exists (it always does, for shadow/selection) — flipping the flag OFF would
// NOT disable it. Gate here so flag-off routes back to the legacy server path.
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
if (wrongCompositionFile(deps, targetPath)) return false;
try {
const serializedBefore = sdkSession.serialize();
sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate));
const after = sdkSession.serialize();
if (after === serializedBefore) return false;
// Undo baseline = exact on-disk bytes (matching the style/delete paths), so
// undoing a timing edit restores the file verbatim instead of a normalized
// full-DOM re-emit. Falls back to serializedBefore when no reader is wired.
const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
return false;
}
}
export async function sdkTimingBatchPersist(
changes: Array<{
hfId: string;
timingUpdate: { start?: number; duration?: number; trackIndex?: number };
}>,
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
const timingSrc = deps.readProjectFile;
for (const change of changes) {
void recordResolverParity(
sdkSession,
change.hfId,
"setTiming",
timingSrc ? () => timingSrc(targetPath) : undefined,
);
}
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
if (!sdkSession || wrongCompositionFile(deps, targetPath)) return false;
if (changes.some((change) => !sdkSession.getElement(change.hfId))) return false;
try {
const serializedBefore = sdkSession.serialize();
sdkSession.batch(() => {
for (const change of changes) sdkSession.setTiming(change.hfId, change.timingUpdate);
});
const after = sdkSession.serialize();
if (after === serializedBefore) return false;
const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
trackStudioEvent("sdk_cutover_success", {
hfId: changes[0]?.hfId ?? null,
opCount: changes.length,
});
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", {
hfId: changes[0]?.hfId ?? null,
error: String(err),
});
return false;
}
}
type SdkGsapTweenOp =
| { kind: "add"; target: string; spec: GsapTweenSpec }
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
| { kind: "remove"; animationId: string };
export function sdkGsapTweenPersist(
targetPath: string,
op: SdkGsapTweenOp,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
// Resolver tripwire — runs BEFORE this function's own cutover gate (decoupled).
// add targets an element (element-resolution parity); set/remove target an
// animationId (animation-resolution parity). Done here, not via
// dispatchGsapOpAndPersist's resolverTarget, because the gate below returns
// before that call when cutover is off.
if (op.kind === "add") {
const gsapSrc = deps.readProjectFile;
void recordResolverParity(
sdkSession,
op.target,
"addGsapTween",
gsapSrc ? () => gsapSrc(targetPath) : undefined,
);
} else {
recordAnimationResolverParity(
sdkSession,
op.animationId,
op.kind === "set" ? "setGsapTween" : "removeGsapTween",
);
}
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
// matches the other three chokepoints' discipline.
if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false);
if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
return Promise.resolve(false);
// dispatchGsapOpAndPersist returns false on before===after — that catches stale
// animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
// back to the server path. This subsumes explicit existence guards for set/remove.
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => {
s.batch(() => {
if (op.kind === "add") {
s.addGsapTween(op.target, op.spec);
} else if (op.kind === "set") {
s.setGsapTween(op.animationId, op.properties);
} else {
s.removeGsapTween(op.animationId);
}
});
});
}
async function dispatchGsapOpAndPersist(
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options: CutoverOptions | undefined,
dispatch: (s: Composition) => void,
resolverTarget?: { animationId: string; opLabel: string },
): Promise<boolean> {
// Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
// the SDK can't resolve the animationId the server GSAP path is addressing.
if (resolverTarget) {
recordAnimationResolverParity(sdkSession, resolverTarget.animationId, resolverTarget.opLabel);
}
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
// flag OFF → return false → caller falls back to the legacy server path.
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
if (!sdkSession) return false;
if (wrongCompositionFile(deps, targetPath)) return false;
const session = sdkSession;
// Route the whole read-serialize → dispatch → serialize → write through the
// per-file serializer (when provided) so overlapping same-file flushes can't
// interleave their read-modify-write and drop an edit, matching the legacy
// commitMutation path's `gsap-file:${file}` serialization.
const run = async (): Promise<boolean> => {
try {
const serializedBefore = session.serialize();
dispatch(session);
const after = session.serialize();
if (after === serializedBefore) return false;
// Undo baseline = exact on-disk bytes (matching the style/delete paths), so
// undoing a GSAP edit restores the file verbatim instead of a normalized
// full-DOM re-emit. Falls back to serializedBefore when no reader is wired.
const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", { error: String(err) });
return false;
}
};
return deps.serialize ? deps.serialize(`gsap-file:${targetPath}`, run) : run();
}
export function sdkGsapKeyframePersist(
targetPath: string,
animationId: string,
position: number,
value: Record<string, unknown>,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })),
{ animationId, opLabel: "addGsapKeyframe" },
);
}
export function sdkGsapRemoveKeyframePersist(
targetPath: string,
animationId: string,
percentage: number,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }),
{ animationId, opLabel: "removeGsapKeyframe" },
);
}
export function sdkGsapRemovePropertyPersist(
targetPath: string,
animationId: string,
property: string,
from: boolean,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.dispatch({ type: "removeGsapProperty", animationId, property, from }),
{ animationId, opLabel: "removeGsapProperty" },
);
}
export function sdkGsapDeleteAllForSelectorPersist(
targetPath: string,
selector: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
s.dispatch({ type: "deleteAllForSelector", selector }),
);
}
export function sdkGsapRemoveAllKeyframesPersist(
targetPath: string,
animationId: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.dispatch({ type: "removeAllKeyframes", animationId }),
{ animationId, opLabel: "removeAllKeyframes" },
);
}
export function sdkGsapConvertToKeyframesPersist(
targetPath: string,
animationId: string,
resolvedFromValues: Record<string, number | string> | undefined,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }),
{ animationId, opLabel: "convertToKeyframes" },
);
}
type KeyframeSpec = {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
};
type KeyframesPayload = {
targetSelector: string;
position: number;
duration: number;
keyframes: KeyframeSpec[];
ease?: string;
};
/** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
function dispatchWithKeyframes(
s: Composition,
payload: KeyframesPayload,
animationId?: string,
): void {
if (animationId !== undefined) {
s.dispatch({ type: "replaceWithKeyframes", animationId, ...payload });
} else {
s.dispatch({ type: "addWithKeyframes", ...payload });
}
}
export function sdkAddWithKeyframesPersist(
targetPath: string,
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease: string | undefined,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
const payload: KeyframesPayload = {
targetSelector,
position,
duration,
keyframes,
...(ease ? { ease } : {}),
};
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
dispatchWithKeyframes(s, payload),
);
}
export function sdkReplaceWithKeyframesPersist(
targetPath: string,
animationId: string,
targetSelector: string,
position: number,
duration: number,
keyframes: KeyframeSpec[],
ease: string | undefined,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
const payload: KeyframesPayload = {
targetSelector,
position,
duration,
keyframes,
...(ease ? { ease } : {}),
};
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => dispatchWithKeyframes(s, payload, animationId),
{ animationId, opLabel: "replaceWithKeyframes" },
);
}
export async function sdkDeletePersist(
hfId: string,
originalContent: string,
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
): Promise<boolean> {
// Resolver tripwire — runs BEFORE the cutover gate (decoupled).
void recordResolverParity(sdkSession, hfId, "removeElement", () =>
Promise.resolve(originalContent),
);
// Dark-launch gate: flag OFF → legacy server delete path.
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
if (wrongCompositionFile(deps, targetPath)) return false;
try {
const before = sdkSession.serialize();
sdkSession.batch(() => sdkSession.removeElement(hfId));
const after = sdkSession.serialize();
if (after === before) return false;
await persistSdkSerialize(after, targetPath, originalContent, deps, {
label: "Delete element",
});
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
return false;
}
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import type { PatchOperation } from "./sourcePatcher";
import { shouldUseSdkCutover } from "./sdkCutoverEligibility";
const childStyleOp: PatchOperation = {
type: "inline-style",
property: "color",
value: "blue",
childSelector: ":scope > span",
childIndex: 0,
};
describe("shouldUseSdkCutover child-scoped operations", () => {
it("declines child-scoped operations because SDK patch ops target only the parent hfId", () => {
expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false);
});
});
@@ -0,0 +1,122 @@
/**
* Cutover eligibility checks: whether a batch of patch ops is safe to route
* through the SDK cutover path instead of the legacy server path. Split out of
* sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this
* block has no dependency on the persist/dispatch functions there.
*/
import type { PatchOperation } from "./sourcePatcher";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
"inline-style",
"text-content",
"attribute",
"html-attribute",
]);
// Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is
// force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK
// rejects with a throw. Detect that up front and decline the whole batch so it
// takes the server path cleanly, instead of throwing inside the dispatch and
// silently falling back per op.
// ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new
// op for it just reverts to the (working) throw→fallback path until synced.
const RESERVED_CUTOVER_ATTRS = new Set<string>([
"data-hf-id",
"data-composition-id",
"data-width",
"data-height",
"data-start",
"data-end",
"data-track-index",
"data-hold-start",
"data-hold-end",
"data-hold-fill",
]);
function sdkAttrName(op: PatchOperation): string | null {
if (op.type === "attribute") {
return op.property.startsWith("data-") ? op.property : `data-${op.property}`;
}
if (op.type === "html-attribute") return op.property;
return null;
}
function mapsToReservedAttr(op: PatchOperation): boolean {
const name = sdkAttrName(op);
// Lowercase to match the SDK's validateSetAttribute (it lowercases before the
// reserved check), so "DATA-START" is declined up front too; covers both
// `attribute` (prefixed) and `html-attribute` (raw) ops.
return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
}
// ─── html-attribute safety ───────────────────────────────────────────────────
function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
return ops.some(
(op) =>
op.type === "html-attribute" &&
(!isAllowedHtmlAttribute(op.property) ||
(op.value !== null && !isSafeAttributeValue(op.property, op.value))),
);
}
function hasChildScopedOp(ops: PatchOperation[]): boolean {
return ops.some((op) => op.childSelector !== undefined);
}
function hasTextContentOp(ops: PatchOperation[]): boolean {
return ops.some((op) => op.type === "text-content");
}
function targetChildren(target: unknown): unknown[] | null {
if (!target || typeof target !== "object" || !("children" in target)) return null;
const children = target.children;
return Array.isArray(children) ? children : null;
}
function elementTag(element: unknown): string | null {
if (!element || typeof element !== "object" || !("tag" in element)) return null;
const tag = element.tag;
return typeof tag === "string" ? tag.toLowerCase() : null;
}
// Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
// Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
// HTMLElement` — that runtime check catches the same set, but we can't use it
// here because `target` is a plain SDK object, not a DOM Element. If linkedom
// (or a future parser) surfaces additional foreign-content elements as
// non-HTMLElement, add them here.
const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
if (!hasTextContentOp(ops)) return false;
const children = targetChildren(target);
if (!children) return false;
// Legacy patch-element replaces the whole element for multi-child targets and
// for single non-HTML children. The SDK text patch stream stores a scalar
// inverse, so those shapes cannot be made both byte-identical and undo-safe
// here. Let the server path remain authoritative for them.
if (children.length > 1) return true;
const tag = elementTag(children[0]);
return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
}
export function shouldUseSdkCutover(
flagEnabled: boolean,
hasSession: boolean,
hfId: string | null | undefined,
ops: PatchOperation[],
): boolean {
return (
flagEnabled &&
hasSession &&
!!hfId &&
ops.length > 0 &&
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
// SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
!hasChildScopedOp(ops) &&
!ops.some(mapsToReservedAttr) &&
!hasUnsafeHtmlAttributeOp(ops)
);
}
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { openComposition } from "@hyperframes/sdk";
import { patchElementInHtml } from "../../../studio-server/src/helpers/sourceMutation.js";
import type { PatchOperation } from "./sourcePatcher";
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
const shell = (body: string) => `<!DOCTYPE html>
<html><head></head><body>${body}</body></html>`;
async function applySdkDomCutover(source: string, ops: PatchOperation[]): Promise<string> {
const session = await openComposition(source, { history: false });
session.batch(() => {
for (const op of patchOpsToSdkEditOps("hf-target", ops)) {
session.dispatch(op);
}
});
return session.serialize();
}
const cases: Array<{ name: string; source: string; ops: PatchOperation[] }> = [
{
name: "coalesces multi-property inline style including transform and custom props",
source: shell(
'<div data-hf-id="hf-target" style="opacity: 0.5; inset: 0; transform-origin: 0 0">Old<span data-hf-id="hf-child">Child</span></div>',
),
ops: [
{ type: "inline-style", property: "transform", value: "translateX(10px)" },
{ type: "inline-style", property: "transform-origin", value: "50% 50%" },
{ type: "inline-style", property: "--x", value: "12px" },
],
},
{
name: "removes one inline style from a multi-property declaration",
source: shell(
'<div data-hf-id="hf-target" style="opacity: 0.5; inset: 0; transform-origin: 0 0">Old</div>',
),
ops: [{ type: "inline-style", property: "opacity", value: null }],
},
{
name: "preserves semicolon-bearing CSS values when updating another style",
source: shell(
'<div data-hf-id="hf-target" style="background: url(data:image/svg+xml;utf8,<svg></svg>); color: red; opacity: 0.5">Old</div>',
),
ops: [{ type: "inline-style", property: "color", value: "blue" }],
},
{
name: "sets direct text content",
source: shell('<div data-hf-id="hf-target">Old</div>'),
ops: [{ type: "text-content", property: "text", value: "New" }],
},
{
name: "sets text on the single child target used by the legacy path",
source: shell('<button data-hf-id="hf-target"><span data-hf-id="hf-child">Old</span></button>'),
ops: [{ type: "text-content", property: "text", value: "New" }],
},
{
name: "sets text on the single child target while preserving parent text",
source: shell('<div data-hf-id="hf-target">Lead <span data-hf-id="hf-child">Old</span></div>'),
ops: [{ type: "text-content", property: "text", value: "New" }],
},
{
name: "sets data attributes through attribute ops",
source: shell('<div data-hf-id="hf-target" data-old="1">Old</div>'),
ops: [{ type: "attribute", property: "mode", value: "hero" }],
},
{
name: "removes data attributes through attribute ops",
source: shell('<div data-hf-id="hf-target" data-mode="hero">Old</div>'),
ops: [{ type: "attribute", property: "mode", value: null }],
},
{
name: "sets allowed html attributes",
source: shell('<a data-hf-id="hf-target" href="https://example.com">Old</a>'),
ops: [{ type: "html-attribute", property: "aria-label", value: "Primary link" }],
},
{
name: "sets shorthand over existing longhands (inset over top/right/bottom/left)",
source: shell(
'<div data-hf-id="hf-target" style="top: 10px; right: 10px; bottom: 10px; left: 10px; opacity: 1">Old</div>',
),
ops: [{ type: "inline-style", property: "inset", value: "0" }],
},
{
name: "sets longhand over existing shorthand (top over inset)",
source: shell('<div data-hf-id="hf-target" style="inset: 0; opacity: 1">Old</div>'),
ops: [{ type: "inline-style", property: "top", value: "10px" }],
},
{
name: "mixed-type batch: inline-style + text-content in one op list",
source: shell('<div data-hf-id="hf-target" style="color: red">Old</div>'),
ops: [
{ type: "inline-style", property: "color", value: "blue" },
{ type: "text-content", property: "text", value: "New" },
],
},
{
name: "mixed-type batch: inline-style + attribute in one op list",
source: shell('<div data-hf-id="hf-target" style="opacity: 0.5" data-mode="default">Old</div>'),
ops: [
{ type: "inline-style", property: "opacity", value: "1" },
{ type: "attribute", property: "mode", value: "hero" },
],
},
];
describe("SDK cutover DOM serialization parity", () => {
it.each(cases)("$name", async ({ source, ops }) => {
const legacy = patchElementInHtml(source, { hfId: "hf-target" }, ops);
expect(legacy.matched).toBe(true);
await expect(applySdkDomCutover(source, ops)).resolves.toBe(legacy.html);
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Studio PatchOperation[] → SDK EditOp[] mapping.
*
* Lives in its own module so both the cutover path (sdkCutover.ts) and the
* resolver-shadow tripwire (sdkResolverShadow.ts) can use it without a circular
* import between those two.
*
* Multiple inline-style ops are coalesced into a single setStyle (the SDK
* batches style changes naturally). One SDK op is emitted per non-style op.
*/
import type { EditOp } from "@hyperframes/sdk";
import type { PatchOperation } from "./sourcePatcher";
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
let hasStyles = false;
for (const op of ops) {
if (op.type === "inline-style") {
styles[op.property] = op.value;
hasStyles = true;
} else if (op.type === "text-content") {
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
} else if (op.type === "attribute") {
result.push({
type: "setAttribute",
target: hfId,
name: op.property.startsWith("data-") ? op.property : `data-${op.property}`,
value: op.value,
});
} else if (op.type === "html-attribute") {
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
}
}
if (hasStyles) {
result.unshift({ type: "setStyle", target: hfId, styles });
}
return result;
}
@@ -0,0 +1,780 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
sdkResolverShadowCheck,
runResolverShadow,
recordResolverParity,
recordAnimationResolverParity,
evaluateSoakGate,
recordAttempt,
flushAttemptCounts,
__resetAttemptSchedulingForTests,
type SdkResolverMismatch,
} from "./sdkResolverShadow";
import type { PatchOperation } from "./sourcePatcher";
import { openComposition } from "@hyperframes/sdk";
// ─── Telemetry capture ────────────────────────────────────────────────────────
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
const flushViaBeacon = vi.fn();
vi.mock("./studioTelemetry", () => ({
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
trackedEvents.push({ event, props }),
flushViaBeacon: () => flushViaBeacon(),
}));
beforeEach(() => {
trackedEvents.length = 0;
flushViaBeacon.mockClear();
});
const lastShadow = () =>
trackedEvents.filter((e) => e.event === "sdk_resolver_shadow").at(-1)?.props;
// ─── Flag mock ────────────────────────────────────────────────────────────────
// manualEditingAvailability reads env at module load time, so we mock the
// module to control flag values per test group.
// Default false in tests so shadow is opt-in per test (real default is true).
const mockFlags = { STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false };
vi.mock("../components/editor/manualEditingAvailability", () => ({
get STUDIO_SDK_RESOLVER_SHADOW_ENABLED() {
return mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED;
},
get STUDIO_SDK_CUTOVER_ENABLED() {
return false;
},
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const BASE_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
</body></html>`;
// Prevents setStyle from applying so the read-back value differs from expected.
// Used in C9 and D11 to simulate a silent SDK value-dispatch bug.
async function makePoisonedStyleSession() {
const session = await openComposition(BASE_HTML);
const origDispatch = session.dispatch.bind(session);
session.dispatch = (op) => {
if (typeof op === "object" && "type" in op && op.type === "setStyle") return;
origDispatch(op);
};
return session;
}
// ─── A. Flag gating ───────────────────────────────────────────────────────────
describe("A. Flag gating", () => {
it("A1: flag off → no telemetry, SDK path not touched", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
const session = await openComposition(BASE_HTML);
const spy = vi.spyOn(session, "getElement");
runResolverShadow(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(trackedEvents).toHaveLength(0);
expect(spy).not.toHaveBeenCalled();
});
it("A2: flag on + divergence → emits exactly one telemetry event", async () => {
// runResolverShadow emits only on divergence, so force one (poisoned dispatch
// → value_mismatch). A parity edit is silent (see B-parity-silent).
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await makePoisonedStyleSession();
runResolverShadow(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
});
it("A2b: flag on + parity → emits nothing (divergence-only)", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
runResolverShadow(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
it("A2c: empty session → ONE tagged session_empty event per session, no attempt", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
flushAttemptCounts(); // drain counts left by earlier tests
const session = await openComposition("<!DOCTYPE html><html><body></body></html>");
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
runResolverShadow(session, "hf-anything", ops);
runResolverShadow(session, "hf-other", ops); // repeat edits do not re-emit
const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow");
// The modeling gap stays VISIBLE (silence would blind the tripwire to the
// exact class that exposed the template-comp bug) but is distinguishable
// and rate-limited to once per session instance.
expect(events).toHaveLength(1);
expect(events[0]?.props.sessionEmpty).toBe(true);
expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty");
expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator
});
it("A3: shadow depends ONLY on shadow flag, not on STUDIO_SDK_CUTOVER_ENABLED", async () => {
// The mock always returns STUDIO_SDK_CUTOVER_ENABLED=false. Use a divergence
// (poisoned session) so the flag-on case emits; flag-off must stay silent.
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
const session = await makePoisonedStyleSession();
runResolverShadow(session, "hf-box", [{ type: "inline-style", property: "color", value: "x" }]);
expect(trackedEvents).toHaveLength(0); // cutover off, shadow off → no event
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
runResolverShadow(session, "hf-box", [{ type: "inline-style", property: "color", value: "x" }]);
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); // shadow on regardless
});
it("A4: null/undefined hfId is a safe no-op (no event, no throw)", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
expect(() => runResolverShadow(session, null, ops)).not.toThrow();
expect(() => runResolverShadow(session, undefined, ops)).not.toThrow();
expect(trackedEvents).toHaveLength(0);
});
});
// ─── B. Telemetry-only (no side effects on real write) ────────────────────────
describe("B. Telemetry-only / no side effects", () => {
it("B4: no disk write — shadow never calls writeProjectFile", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const writeProjectFile = vi.fn();
const session = await openComposition(BASE_HTML);
runResolverShadow(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
// writeProjectFile is a deps-level function not in scope here; verify by
// checking sdkResolverShadowCheck itself never touches it — it's not passed
// in at all, so any call would be a TypeError at runtime.
expect(writeProjectFile).not.toHaveBeenCalled();
});
it("B5: the LIVE session is restored after the check (cutover before===after stays correct)", async () => {
// The session is shared with the cutover path. The shadow dispatches into it
// to read values back, then MUST undo those mutations — otherwise the edit is
// pre-applied and the following sdkCutoverPersist sees before === after and
// silently falls back to the server path.
const session = await openComposition(BASE_HTML);
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red");
const mismatches = sdkResolverShadowCheck(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(mismatches).toHaveLength(0); // SDK applied blue == expected → parity
// …but the session is back to its pre-check state, NOT left on "blue".
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red");
});
it("B5b: a real cutover-style serialize diff survives a preceding shadow run", async () => {
// End-to-end of the bug: shadow runs, THEN a cutover-style before/dispatch/
// after still produces a diff (proving shadow left no residue).
const session = await openComposition(BASE_HTML);
sdkResolverShadowCheck(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
const before = session.serialize();
session.dispatch({ type: "setStyle", target: "hf-box", styles: { color: "blue" } });
const after = session.serialize();
expect(after).not.toBe(before); // cutover would write, not fall back
});
it("B6: exception inside shadow never propagates to caller", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
session.dispatch = () => {
throw new Error("sdk exploded");
};
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
expect(() => runResolverShadow(session, "hf-box", ops)).not.toThrow();
// A dispatch_error mismatch is still emitted (via telemetry)
const ev = lastShadow();
expect(ev).toBeDefined();
expect(ev?.mismatchCount).toBe(1);
});
});
// ─── C. Resolver-parity detection ────────────────────────────────────────────
describe("C. Resolver-parity detection", () => {
it("C7: match → mismatchCount 0", async () => {
const session = await openComposition(BASE_HTML);
const mismatches = sdkResolverShadowCheck(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(mismatches).toHaveLength(0);
});
it("C8: element_not_found fires when SDK resolver returns null (v0.6.110 class)", () => {
// Simulate the regression: SDK session cannot resolve the hfId the server
// would address (e.g. scoped-id mismatch, resolver bug).
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
typeof sdkResolverShadowCheck
>[0];
const mismatches = sdkResolverShadowCheck(
session as unknown as Parameters<typeof sdkResolverShadowCheck>[0],
"hf-box",
[{ type: "inline-style", property: "color", value: "red" }],
);
expect(mismatches).toHaveLength(1);
expect(mismatches[0]).toMatchObject<SdkResolverMismatch>({
kind: "element_not_found",
hfId: "hf-box",
});
});
it("C8 inverse: no element_not_found when SDK resolves (server also resolves)", async () => {
const session = await openComposition(BASE_HTML);
const mismatches = sdkResolverShadowCheck(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
});
it("C9: value_mismatch when dispatch yields different value than expected", async () => {
const session = await makePoisonedStyleSession();
const mismatches = sdkResolverShadowCheck(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(mismatches).toHaveLength(1);
expect(mismatches[0]).toMatchObject<SdkResolverMismatch>({
kind: "value_mismatch",
hfId: "hf-box",
property: "color",
expected: "blue",
});
});
it("C8 runtime-node filter: hfId absent from source → suppressed (not a resolver bug)", () => {
// The studio resolved a live-DOM element to an hf-id that the SDK session
// doesn't contain AND that never appears in the on-disk source — it's a
// node a composition <script> created at runtime (e.g. caption spans). Not
// a resolver divergence; suppress.
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
typeof sdkResolverShadowCheck
>[0];
const source = `<div data-hf-id="hf-static">no runtime id here</div>`;
const mismatches = sdkResolverShadowCheck(
session,
"hf-runtimeonly",
[{ type: "inline-style", property: "color", value: "red" }],
source,
);
expect(mismatches).toHaveLength(0);
});
it("C8 runtime-node filter: hfId PRESENT in source but missing from session → still flagged (real bug)", () => {
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
typeof sdkResolverShadowCheck
>[0];
const source = `<div data-hf-id="hf-realbug">in source, not in SDK session</div>`;
const mismatches = sdkResolverShadowCheck(
session,
"hf-realbug",
[{ type: "inline-style", property: "color", value: "red" }],
source,
);
expect(mismatches).toHaveLength(1);
expect(mismatches[0]?.kind).toBe("element_not_found");
});
it("C8 sourceHfIdCount: emitted element_not_found carries source occurrence count", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
// One unrelated element so the session isn't "empty" (empty sessions are
// skipped as structural modeling gaps) — it just can't resolve hf-dup.
const session = {
getElement: () => null,
getElements: () => [{ id: "hf-other" }],
} as unknown as Composition;
// id present twice in source (duplicate-id ambiguity) but absent from session
const source = `<div data-hf-id="hf-dup">a</div><div data-hf-id="hf-dup">b</div>`;
runResolverShadow(
session,
"hf-dup",
[{ type: "inline-style", property: "color", value: "red" }],
source,
);
expect(lastShadow()?.sourceHfIdCount).toBe(2);
});
it("C8 sourceLooseMatchOnly: hfId matches source only as plain text, not a data-hf-id attribute", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = {
getElement: () => null,
getElements: () => [{ id: "hf-other" }],
} as unknown as Composition;
// "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget".
const source = `<div class="hf-widget-container">no attribute match here</div>`;
runResolverShadow(
session,
"hf-widget",
[{ type: "inline-style", property: "color", value: "red" }],
source,
);
const ev = lastShadow();
expect(ev?.sourceHfIdCount).toBe(0);
expect(ev?.sourceLooseMatchOnly).toBe(true);
});
it("C10: unmappable op type produces no mismatch (excluded, not flagged)", async () => {
const session = await openComposition(BASE_HTML);
// "unknown-op" is not in MAPPED_OP_TYPES, so it must be silently excluded.
const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
const mismatches = sdkResolverShadowCheck(session, "hf-box", ops);
expect(mismatches).toHaveLength(0);
});
});
// ─── D. Redaction ─────────────────────────────────────────────────────────────
describe("D. Redaction", () => {
it("D11: telemetry payload carries kind/hfId/count but NOT raw style value or text", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await makePoisonedStyleSession();
const sensitiveValue = "rgba(255, 0, 0, 0.5)";
runResolverShadow(session, "hf-box", [
{ type: "inline-style", property: "color", value: sensitiveValue },
]);
const ev = lastShadow();
expect(ev).toBeDefined();
expect(ev?.mismatchCount).toBe(1);
// The raw sensitive value must NOT appear in the serialized mismatches
const serialized = JSON.stringify(ev?.mismatches ?? "");
expect(serialized).not.toContain(sensitiveValue);
// But the kind and hfId must be present
expect(serialized).toContain("value_mismatch");
expect(serialized).toContain("hf-box");
});
it("D11: text-content value is fully redacted (replaced with length marker)", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
const origDispatch = session.dispatch.bind(session);
// Prevent setText from applying so text value differs
session.dispatch = (op) => {
if (typeof op === "object" && "type" in op && op.type === "setText") return;
origDispatch(op);
};
const secretText = "confidential user content";
runResolverShadow(session, "hf-box", [
{ type: "text-content", property: "text", value: secretText },
]);
const ev = lastShadow();
const serialized = JSON.stringify(ev?.mismatches ?? "");
expect(serialized).not.toContain(secretText);
expect(serialized).toContain("[redacted len=");
});
});
// ─── E. Soak gate ─────────────────────────────────────────────────────────────
describe("E. Soak gate", () => {
it("E12: zero divergences → parity-proven", () => {
expect(evaluateSoakGate(0)).toBe("parity-proven");
});
it("E12: one divergence → divergence-detected", () => {
expect(evaluateSoakGate(1)).toBe("divergence-detected");
});
it("E12: many divergences → divergence-detected", () => {
expect(evaluateSoakGate(100)).toBe("divergence-detected");
});
});
// ─── F. recordResolverParity (extended coverage: timing / delete / gsap-add) ──
describe("F. recordResolverParity", () => {
it("emits element_not_found when the SDK cannot resolve the target", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-missing", "setTiming");
const ev = lastShadow();
expect(ev?.mismatchCount).toBe(1);
expect(ev?.opLabel).toBe("setTiming");
expect(JSON.stringify(ev?.mismatches)).toContain("element_not_found");
});
it("emits nothing when the target resolves (parity)", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-box", "removeElement");
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
it("is a no-op (no SDK touch) when the flag is off", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
const session = await openComposition(BASE_HTML);
const spy = vi.spyOn(session, "getElement");
await recordResolverParity(session, "hf-missing", "setTiming");
expect(trackedEvents).toHaveLength(0);
expect(spy).not.toHaveBeenCalled();
});
it("never mutates the session (read-only resolver check)", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-box", "setTiming");
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red"); // unchanged
});
it("suppresses the emit when the hfId is absent from source (runtime node)", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-runtime", "setTiming", () =>
Promise.resolve('<div data-hf-id="hf-other"></div>'),
);
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
it("emits with sourceHfIdCount=1 when the hfId IS in source but missing from the session", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-ghost", "setTiming", () =>
Promise.resolve('<div data-hf-id="hf-ghost"></div>'),
);
const ev = lastShadow();
expect(ev?.mismatchCount).toBe(1);
expect(ev?.sourceHfIdCount).toBe(1);
});
it("reports sourceHfIdCount=2 for a duplicate-id source (ambiguity)", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-dup", "setTiming", () =>
Promise.resolve('<a data-hf-id="hf-dup"></a><b data-hf-id="hf-dup"></b>'),
);
expect(lastShadow()?.sourceHfIdCount).toBe(2);
});
it("emits without sourceHfIdCount when no reader is supplied (status quo)", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-missing", "setTiming");
const ev = lastShadow();
expect(ev?.mismatchCount).toBe(1);
expect(ev?.sourceHfIdCount).toBeUndefined();
});
it("fails open: a readSource error still emits (no suppression), tagged sourceReadFailed", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-missing", "setTiming", () =>
Promise.reject(new Error("read failed")),
);
const ev = lastShadow();
expect(ev?.mismatchCount).toBe(1);
expect(ev?.sourceHfIdCount).toBeUndefined();
// Distinguishes "reader threw" from "no reader wired" — every wild emission
// of the setTiming class had an absent sourceHfIdCount and the two cases
// were indistinguishable in telemetry.
expect(ev?.sourceReadFailed).toBe(true);
});
it("does not tag sourceReadFailed when no reader is supplied", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-missing", "setTiming");
expect(lastShadow()?.sourceReadFailed).toBeUndefined();
});
it("empty session → ONE tagged session_empty event, no attempt, no element_not_found", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
flushAttemptCounts(); // drain any counts left by earlier tests
const session = await openComposition("<!DOCTYPE html><html><body></body></html>");
await recordResolverParity(session, "hf-anything", "setTiming");
await recordResolverParity(session, "hf-other", "setTiming"); // no re-emit
const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow");
expect(events).toHaveLength(1);
expect(events[0]?.props.sessionEmpty).toBe(true);
expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty");
expect(JSON.stringify(events[0]?.props.mismatches)).not.toContain("element_not_found");
expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator
});
it("tags sourceLooseMatchOnly when hfId matches source only as plain text, not a data-hf-id attribute", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
// "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget".
await recordResolverParity(session, "hf-widget", "setTiming", () =>
Promise.resolve('<div class="hf-widget-container"></div>'),
);
const ev = lastShadow();
expect(ev?.mismatchCount).toBe(1);
expect(ev?.sourceHfIdCount).toBe(0);
expect(ev?.sourceLooseMatchOnly).toBe(true);
});
});
// ─── G. recordAnimationResolverParity (GSAP animationId ops) ──────────────────
const GSAP_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red">Hello</div>
<script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
</body></html>`;
const GSAP_UNMATCHED_SELECTOR_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red">Hello</div>
<script>var tl = gsap.timeline({ paused: true }); tl.to("#coral-band", { x: 100, duration: 1 }, 3);</script>
</body></html>`;
describe("G. recordAnimationResolverParity", () => {
it("emits animation_not_found when the SDK cannot resolve the animationId", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(GSAP_HTML);
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
const ev = lastShadow();
expect(ev?.mismatchCount).toBe(1);
expect(ev?.opLabel).toBe("setGsapTween");
expect(JSON.stringify(ev?.mismatches)).toContain("animation_not_found");
});
it("emits nothing when the animationId resolves to a located animation", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(GSAP_HTML);
const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
expect(realId).not.toBe(""); // fixture has a tween on hf-box
recordAnimationResolverParity(session, realId, "removeGsapTween");
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
it("is a no-op when the flag is off", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
const session = await openComposition(GSAP_HTML);
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
expect(trackedEvents).toHaveLength(0);
});
it("emits nothing when the animationId only resolves via getAllAnimationIds (no live DOM match) — repro of the v0.7.31 false-positive", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(GSAP_UNMATCHED_SELECTOR_HTML);
const unmatchedId = [...session.getAllAnimationIds()][0] ?? "";
expect(unmatchedId).not.toBe("");
// Confirms the bug this fixes: the id is NOT attached to any element.
expect(session.getElements().some((el) => el.animationIds.includes(unmatchedId))).toBe(false);
recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes");
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
});
// ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
// PostHog showed ~445 false `element_not_found` events, all on a bare leaf id
// (hf-0ytc / #subscribe-btn) inside an inlined sub-composition. The studio reads
// the bare data-hf-id off the DOM and the cutover dispatch resolves it via
// resolveScoped (which locates the leaf inside the host subtree). But the shadow
// resolved via Composition.getElement, which is canonical-only for a bare id and
// returns null for a scoped element — so it flagged a divergence the real
// dispatch path would not hit. The shadow now mirrors dispatch via resolveSnapshot.
describe("H. inlined sub-composition leaf", () => {
// host carries data-composition-file → new scope; leaf's scopedId is
// "hf-host/hf-leaf" but its raw data-hf-id (what the studio reads) is bare.
const INLINED_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<div data-hf-id="hf-leaf" style="color: red">Subscribe</div>
</div>
</div>
</body></html>`;
it("getElement(bareLeaf) is null (canonical-only) — the trap the shadow used to hit", async () => {
const session = await openComposition(INLINED_HTML);
expect(session.getElement("hf-leaf")).toBeNull();
expect(session.getElement("hf-host/hf-leaf")).not.toBeNull();
});
it("recordResolverParity emits NOTHING for a bare leaf inside a sub-comp", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(INLINED_HTML);
await recordResolverParity(session, "hf-leaf", "setTiming");
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
it("sdkResolverShadowCheck does not flag element_not_found for a bare leaf in a sub-comp", async () => {
const session = await openComposition(INLINED_HTML);
const mismatches = sdkResolverShadowCheck(session, "hf-leaf", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
});
});
// ─── I. Attempt counter (denominator for the soak gate) ───────────────────────
describe("I. recordAttempt / flushAttemptCounts", () => {
beforeEach(() => {
// Drain any counts left over from a prior test so each test starts clean.
flushAttemptCounts();
});
it("flushAttemptCounts returns null when nothing has been recorded", () => {
expect(flushAttemptCounts()).toBeNull();
});
it("increments the counter for a given op label", () => {
recordAttempt("setTiming");
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
});
it("accumulates multiple calls with the same label", () => {
recordAttempt("setTiming");
recordAttempt("setTiming");
recordAttempt("setTiming");
expect(flushAttemptCounts()).toEqual({ setTiming: 3 });
});
it("tracks different labels independently", () => {
recordAttempt("setTiming");
recordAttempt("addGsapTween");
recordAttempt("setTiming");
expect(flushAttemptCounts()).toEqual({ setTiming: 2, addGsapTween: 1 });
});
it("resets to empty after a flush", () => {
recordAttempt("setTiming");
flushAttemptCounts();
expect(flushAttemptCounts()).toBeNull();
});
});
describe("I. attempt counting inside the three emit functions", () => {
beforeEach(() => {
flushAttemptCounts();
});
it("runResolverShadow counts an attempt on the parity (silent) path", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
runResolverShadow(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 });
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
it("runResolverShadow counts an attempt on the divergence (emits) path too", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
runResolverShadow(session, "hf-missing", [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 });
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
});
it("recordResolverParity counts an attempt on the parity (silent) path", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-box", "setTiming");
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
});
it("recordResolverParity counts an attempt on the divergence (emits) path too", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-missing", "setTiming");
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
});
it("recordAnimationResolverParity counts an attempt on the parity (silent) path", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(GSAP_HTML);
const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
expect(realId).not.toBe(""); // fixture has a tween on hf-box, see block G above
recordAnimationResolverParity(session, realId, "removeGsapTween");
expect(flushAttemptCounts()).toEqual({ removeGsapTween: 1 });
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
it("recordAnimationResolverParity counts an attempt on the divergence (emits) path too", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(GSAP_HTML);
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
expect(flushAttemptCounts()).toEqual({ setGsapTween: 1 });
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
});
it("counts accumulate across multiple different chokepoints in one rollup", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-box", "setTiming");
await recordResolverParity(session, "hf-box", "setTiming");
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
expect(flushAttemptCounts()).toEqual({ setTiming: 2, setGsapTween: 1 });
});
it("does not count an attempt when the flag is off", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
const session = await openComposition(BASE_HTML);
runResolverShadow(session, "hf-box", [
{ type: "inline-style", property: "color", value: "blue" },
]);
await recordResolverParity(session, "hf-box", "setTiming");
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
expect(flushAttemptCounts()).toBeNull();
});
});
describe("I. production rollup wiring", () => {
beforeEach(() => {
flushAttemptCounts();
__resetAttemptSchedulingForTests();
vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "clearInterval", "clearTimeout"] });
});
afterEach(() => {
__resetAttemptSchedulingForTests();
vi.useRealTimers();
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
});
it("does not emit a rollup event when nothing was recorded", () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
vi.advanceTimersByTime(5 * 60_000);
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt")).toHaveLength(0);
});
it("emits a sdk_resolver_shadow_attempt rollup event every 5 minutes after the first attempt", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-box", "setTiming");
vi.advanceTimersByTime(5 * 60_000);
const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt");
expect(rollups).toHaveLength(1);
expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 }));
});
it("flushes a rollup and forces a beacon delivery on visibilitychange -> hidden", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-box", "setTiming");
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt");
expect(rollups).toHaveLength(1);
expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 }));
// Delivery must not depend on studioTelemetry's own visibilitychange listener
// winning a race — this module forces its own beacon flush.
expect(flushViaBeacon).toHaveBeenCalledTimes(1);
});
it("does not register a duplicate visibilitychange listener after a scheduling reset", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-box", "setTiming");
__resetAttemptSchedulingForTests();
await recordResolverParity(session, "hf-box", "setTiming"); // re-arms scheduling, incl. listener
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
// If the reset had leaked the old listener, this would fire twice.
expect(flushViaBeacon).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,546 @@
/**
* SDK resolver-parity tripwire (telemetry-only).
*
* Checks whether the SDK session resolves the same element id the server
* patch path would target, then optionally verifies value parity after an
* in-memory dispatch. Emits `sdk_resolver_shadow` on any divergence.
*
* Headline signal: `element_not_found` — the resolver divergence class that
* caused the v0.6.110 regression. The writer-parity suite (#1533) cannot see
* this class; this tripwire exists specifically to catch it.
*
* Decoupled from `STUDIO_SDK_CUTOVER_ENABLED`. Gated by its own flag
* `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (default ON during the soak — collect
* wild telemetry; flip off / remove once resolver parity is proven).
* Telemetry-only — never writes to disk, never affects the user-visible edit.
*/
import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
import type { PatchOperation } from "./sourcePatcher";
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SdkResolverMismatch {
kind:
| "element_not_found"
| "value_mismatch"
| "dispatch_error"
| "animation_not_found"
| "session_empty";
hfId?: string;
animationId?: string;
property?: string;
expected?: string | null;
actual?: string | null | undefined;
error?: string;
}
// ─── Op helpers ───────────────────────────────────────────────────────────────
// Drop studio-internal data-hf-* markers the SDK model doesn't represent.
function isShadowableOp(op: PatchOperation): boolean {
const name =
op.type === "attribute"
? op.property.startsWith("data-")
? op.property
: `data-${op.property}`
: op.type === "html-attribute"
? op.property
: null;
return name === null || !name.startsWith("data-hf-");
}
const MAPPED_OP_TYPES = new Set(["inline-style", "text-content", "attribute", "html-attribute"]);
// ─── Read-back helpers ────────────────────────────────────────────────────────
function kebabToCamel(prop: string): string {
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
function normalizeText(v: string | null | undefined): string | null {
if (v == null) return null;
const t = v.trim();
return t === "" ? null : t;
}
type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
type AttrMap = Record<string, string | null>;
/**
* Resolve an hf-id to its snapshot the SAME way the SDK dispatch path does
* (engine/model.ts resolveScoped), NOT via Composition.getElement.
*
* getElement is canonical-only for a bare id by design — it deliberately will
* not resolve a bare id to a non-canonical (sub-composition) element, so that
* removeElement(bareId) and getElement(bareId) agree on the same instance
* (session.subcomp.test "ambiguous bare id" suite). But the cutover persist
* path dispatches the studio's bare data-hf-id, and dispatch resolves it via
* resolveScoped, which locates the leaf anywhere (canonical preferred, else
* first match). So getElement under-resolves a bare leaf that lives inside an
* inlined sub-composition (scopedId "host/leaf") — exactly the false
* `element_not_found` this tripwire was emitting for inlined compositions.
*
* Mirror resolveScoped here: exact scoped-path match, then canonical bare
* match, then first bare match — the resolvability dispatch actually has.
*/
// Count static `data-hf-id="<id>"` occurrences (both quote styles) in source.
// Substring split, not regex — no escaping, and the id never contains a quote.
function countHfIdInSource(source: string, id: string): number {
return (
source.split(`data-hf-id="${id}"`).length - 1 + (source.split(`data-hf-id='${id}'`).length - 1)
);
}
function resolveSnapshot(session: Composition, id: string): FlatEl | null {
const els = session.getElements();
const exact = els.find((el) => el.scopedId === id);
if (exact) return exact;
const matches = els.filter((el) => el.id === id);
return matches.find((el) => el.scopedId === el.id) ?? matches[0] ?? null;
}
function checkStyleOp(
op: PatchOperation,
el: FlatEl,
): { expected: string | null; actual: string | null } {
return {
expected: op.value ?? null,
actual: el.inlineStyles[kebabToCamel(op.property)] ?? el.inlineStyles[op.property] ?? null,
};
}
function checkTextOp(
op: PatchOperation,
el: FlatEl,
): { expected: string | null; actual: string | null } {
return { expected: normalizeText(op.value), actual: normalizeText(el.text) };
}
function checkAttrOp(
op: PatchOperation,
el: FlatEl,
): { property: string; expected: string | null; actual: string | null } {
const property =
op.type === "attribute"
? op.property.startsWith("data-")
? op.property
: `data-${op.property}`
: op.property;
return {
property,
expected: op.value ?? null,
actual: (el.attributes as AttrMap)[property] ?? null,
};
}
function checkOpValue(op: PatchOperation, el: FlatEl, hfId: string): SdkResolverMismatch | null {
let property: string;
let expected: string | null;
let actual: string | null;
if (op.type === "inline-style") {
property = op.property;
({ expected, actual } = checkStyleOp(op, el));
} else if (op.type === "text-content") {
property = "text";
({ expected, actual } = checkTextOp(op, el));
} else if (op.type === "attribute" || op.type === "html-attribute") {
({ property, expected, actual } = checkAttrOp(op, el));
} else {
return null;
}
if (actual === expected) return null;
return { kind: "value_mismatch", hfId, property, expected, actual };
}
// ─── Core check (pure — testable without flag) ────────────────────────────────
/**
* Run the resolver shadow check against an already-open SDK session.
*
* Returns an array of mismatches (empty = parity). The value-parity check
* dispatches the ops into the session to read the result back, then UNDOES
* those mutations via the captured inverse patches before returning — the
* session ends exactly as it started. This is essential: the session is shared
* with the cutover path, and a residual shadow mutation would make the
* subsequent sdkCutoverPersist see before === after and silently fall back to
* the server path. Telemetry-only; the server path stays authoritative on disk.
*
* Exported for unit tests; call `runResolverShadow` at call sites.
*/
export function sdkResolverShadowCheck(
session: Composition,
hfId: string,
ops: PatchOperation[],
sourceContent?: string,
): SdkResolverMismatch[] {
if (!resolveSnapshot(session, hfId)) {
// Runtime-node filter: an hf-id absent from the on-disk source the SDK
// parsed was never in the static DOM — it belongs to an element a
// composition <script> creates at runtime (e.g. caption word/group spans),
// which the SDK session cannot model by design. That is NOT a resolver bug,
// so suppress it. An hf-id PRESENT in source but missing from the session IS
// a genuine resolver divergence (the v0.6.110 class) — keep emitting that.
// ponytail: substring match; biases toward keeping signal on a loose hit.
if (sourceContent !== undefined && !sourceContent.includes(hfId)) return [];
// Loose match here vs. countHfIdInSource's strict data-hf-id="..." match in the
// caller (runResolverShadow) means an emitted event can carry sourceHfIdCount: 0 —
// see the comment on that field in runResolverShadow for what 0 means in that case.
return [{ kind: "element_not_found", hfId }];
}
const shadowable = ops.filter(isShadowableOp);
if (shadowable.length === 0) return [];
// Silently skip op batches containing unmapped types — not a resolver bug.
if (shadowable.some((op) => !MAPPED_OP_TYPES.has(op.type))) return [];
// Capture the inverse of the shadow dispatch so we can restore the session.
// `batch` fires a single PatchEvent whose `inversePatches` are already in
// reverse-apply order (session.ts reverses inside buildPatchEvent), so
// applyPatches(inverse) undoes the dispatch with no further reordering. If a
// future SDK refactor ever coalesces batch into a composite with no per-op
// inverse, this restore breaks — keep batch emitting inverse patches.
const inverse: JsonPatchOp[] = [];
const stopCapture = session.on("patch", (e) => inverse.push(...e.inversePatches));
// restore() runs in `finally` so the patch listener is always removed and the
// session is always undone — even if checkOpValue throws between dispatch and
// return. A residual mutation or leaked listener on the shared session is the
// exact cutover-coupling failure mode this module exists to avoid.
try {
try {
const editOps = patchOpsToSdkEditOps(hfId, shadowable);
session.batch(() => {
for (const op of editOps) session.dispatch(op);
});
} catch (err) {
return [{ kind: "dispatch_error", hfId, error: String(err) }];
}
const el = resolveSnapshot(session, hfId);
if (!el) return [{ kind: "element_not_found", hfId }];
return shadowable
.map((op) => checkOpValue(op, el, hfId))
.filter((m): m is SdkResolverMismatch => m !== null);
} finally {
stopCapture();
if (inverse.length > 0) session.applyPatches(inverse);
}
}
// ─── Attempt counter (denominator for the soak gate) ──────────────────────────
//
// The three emit functions below only fire a PostHog event on divergence —
// parity is silent, by design, to avoid firing on every edit. That leaves no
// way to compute a rate (divergences / attempts): we can count failures but
// never attempts. This counter tracks attempts in memory and rolls them up
// into ONE low-frequency event instead of firing per-attempt, which would
// recreate the exact chattiness problem the divergence-only design avoids.
const attemptCounts: Record<string, number> = {};
/**
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
* outcome (parity or divergence). No flag check of its own — only ever called
* from inside the three emit functions below, after their own
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
*/
export function recordAttempt(opLabel: string): void {
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
ensureAttemptFlushScheduled();
}
/**
* Return the accumulated attempt counts since the last flush (or `null` if
* nothing has been recorded — no point emitting an empty rollup), and reset
* the counter to empty.
*/
export function flushAttemptCounts(): Record<string, number> | null {
const keys = Object.keys(attemptCounts);
if (keys.length === 0) return null;
const snapshot: Record<string, number> = {};
for (const key of keys) {
snapshot[key] = attemptCounts[key];
delete attemptCounts[key];
}
return snapshot;
}
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
let attemptVisibilityHandler: (() => void) | null = null;
function flushAndEmitAttempts(): void {
const counts = flushAttemptCounts();
if (counts === null) return;
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
}
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
// start, so a session that never exercises the tripwire never runs a
// background timer.
function ensureAttemptFlushScheduled(): void {
if (!attemptFlushTimer) {
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
}
if (!attemptVisibilityHandler && typeof document !== "undefined") {
attemptVisibilityHandler = () => {
if (document.visibilityState !== "hidden") return;
flushAndEmitAttempts();
// studioTelemetry.ts registers its own visibilitychange listener (on
// window, at module load) that drains its queue via sendBeacon. Listener
// execution order between that handler and this one (on document,
// registered lazily) is not something to rely on — whichever runs
// first could otherwise beacon-flush before or after this rollup lands
// in the queue. Forcing a beacon flush here makes delivery of this
// rollup event correct regardless of that order.
flushViaBeacon();
};
document.addEventListener("visibilitychange", attemptVisibilityHandler);
}
}
/**
* Test-only: clears the lazy timer/listener singleton state so tests can
* verify the "starts on first attempt" behavior in isolation, without an
* earlier test's real-timer interval (or visibilitychange listener) silently
* surviving into a later test. Does NOT touch attemptCounts — only the
* scheduling state. Not part of the public module contract; only imported
* from sdkResolverShadow.test.ts.
*/
export function __resetAttemptSchedulingForTests(): void {
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
attemptFlushTimer = null;
if (attemptVisibilityHandler && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
}
attemptVisibilityHandler = null;
}
// ─── Telemetry ────────────────────────────────────────────────────────────────
// Redact all user-content values before telemetry: style values and text both
// carry user data. Keep only the length so we can detect truncation without
// leaking the actual bytes.
function redactValue(value: string | null | undefined): string | null | undefined {
if (value == null) return value;
return `[redacted len=${value.length}]`;
}
function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatch[] {
return mismatches.map((m) => ({
...m,
expected: redactValue(m.expected),
actual: redactValue(m.actual),
}));
}
/**
* Run the resolver shadow and emit `sdk_resolver_shadow` telemetry.
* No-op when `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` is false.
* Never throws — any exception inside the shadow is swallowed.
*
* Side-effect-free on the live session: sdkResolverShadowCheck dispatches into
* the session to read values back, then undoes those mutations before returning
* (see below). The session is shared with the cutover path, so it MUST end the
* call exactly as it started.
*/
// Sessions whose empty-session modeling gap has already been reported — one
// event per session instance, not one per edit (the per-edit storm is noise;
// the EXISTENCE of the gap is the signal).
const emptySessionReported = new WeakSet<Composition>();
/**
* An empty session structurally cannot resolve ANY id — a modeling gap (empty
* file, comp shape the SDK can't parse into elements), not a resolver
* divergence, and it can't cut over either, so it stays out of the attempt
* denominator. But silence would blind the tripwire to exactly the class that
* exposed the template-comp bug — so emit ONE distinguishable `session_empty`
* event per session, then skip. Returns true when the caller should skip.
*/
function reportEmptySession(session: Composition, opLabel: string): boolean {
if (session.getElements().length !== 0) return false;
if (!emptySessionReported.has(session)) {
emptySessionReported.add(session);
trackStudioEvent("sdk_resolver_shadow", {
opLabel,
sessionEmpty: true,
sessionElementCount: 0,
mismatchCount: 1,
mismatches: JSON.stringify([{ kind: "session_empty" } satisfies SdkResolverMismatch]),
});
}
return true;
}
export function runResolverShadow(
session: Composition,
hfId: string | null | undefined,
ops: PatchOperation[],
sourceContent?: string,
): void {
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
if (!hfId) return;
try {
if (reportEmptySession(session, "dom-edit")) return;
recordAttempt("dom-edit");
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
// Emit only on divergence — parity is silent, matching recordResolverParity
// and recordAnimationResolverParity. Otherwise this fires a PostHog event on
// every style/text/attr edit (the editor's chattiest path) at default-ON.
if (mismatches.length === 0) return;
const isElementNotFound = mismatches.some((m) => m.kind === "element_not_found");
const strictCount =
isElementNotFound && sourceContent !== undefined
? countHfIdInSource(sourceContent, hfId)
: undefined;
trackStudioEvent("sdk_resolver_shadow", {
hfId,
// sessionElementCount > 0 + element_not_found = runtime-only element;
// sessionElementCount === 0 = session is empty/broken (actionable).
sessionElementCount: session.getElements().length,
// Count of data-hf-id="<id>" occurrences in source for an emitted
// element_not_found. >1 = duplicate ids → resolver picked the wrong
// instance; =1 = single static node the SDK parse dropped (foreign-content
// exclusion / sub-comp inlining gap); =0 = the runtime-node filter above
// uses a loose substring match (biased toward keeping signal) while this
// count uses a strict attribute match — see sourceLooseMatchOnly below.
...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
// Loose suppression check matched (kept this event) but the strict
// attribute count came back 0 — see the sourceHfIdCount comment above.
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
mismatchCount: mismatches.length,
mismatches: JSON.stringify(redactMismatches(mismatches)),
});
} catch {
// never propagate from the shadow path
}
}
/**
* Record element-resolution parity for an element-targeted op WITHOUT
* dispatching. Read-only: emits a single `element_not_found` event when the SDK
* can't resolve a target the server path is addressing. This extends the
* tripwire beyond the DOM-edit path (runResolverShadow) to the other
* element-targeted cutover chokepoints — timing, delete, GSAP-tween add — for
* the headline resolver signal, without the cost/mutation of a value check.
*
* No-op when the shadow flag is off; never throws; never mutates the session.
*/
export async function recordResolverParity(
session: Composition | null | undefined,
hfId: string | null | undefined,
opLabel: string,
readSource?: () => Promise<string | undefined>,
): Promise<void> {
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
if (!session || !hfId) return;
try {
if (reportEmptySession(session, opLabel)) return;
recordAttempt(opLabel);
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
// Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
// and the caller runs its own session mutation synchronously right after this call
// returns. getElements() caches and that cache is invalidated on dispatch, so reading
// the count after an await would silently reflect POST-edit state, not the pre-edit
// state this field exists to diagnose.
const sessionElementCount = session.getElements().length;
// Cheap check passed above, so the source read only runs on a real divergence.
let source: string | undefined;
let sourceReadFailed = false;
if (readSource) {
try {
source = await readSource();
} catch {
source = undefined; // fail-open: a read error must not drop a real divergence
sourceReadFailed = true;
}
}
// Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
if (source !== undefined && !source.includes(hfId)) return;
const strictCount = source !== undefined ? countHfIdInSource(source, hfId) : undefined;
trackStudioEvent("sdk_resolver_shadow", {
hfId,
opLabel,
sessionElementCount,
// sourceHfIdCount: strict data-hf-id="..." attribute count. Can be 0 even
// on an emitted (non-suppressed) event — the suppression check above is a
// loose substring match (biased toward keeping signal); see the longer
// comment on this field in runResolverShadow for the full explanation.
...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
// Loose suppression check matched (kept this event) but the strict
// attribute count came back 0 — hfId appeared as plain text (class name,
// comment, script string) but never as a data-hf-id="..." attribute.
// Lets telemetry consumers filter this cohort without parsing the
// sourceHfIdCount comment above.
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
// The reader was wired but threw — distinguishes "read failed, emitted
// fail-open without the suppression/count checks" from "no reader wired".
...(sourceReadFailed ? { sourceReadFailed: true } : {}),
mismatchCount: 1,
mismatches: JSON.stringify([
{ kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
]),
});
} catch {
// never propagate from the shadow path
}
}
/**
* Record animation-resolution parity for an animationId-targeted GSAP op WITHOUT
* dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve
* the animationId the server GSAP path is addressing — the GSAP-edit-surface
* analogue of element_not_found. The SDK's resolvable animation ids are the
* located ids attached to elements (buildAnimationIdMap) OR any id parsed from
* the script regardless of DOM match (getAllAnimationIds) — a target absent
* from both is a resolver divergence.
*
* No-op when the shadow flag is off; never throws; never mutates the session.
*/
export function recordAnimationResolverParity(
session: Composition | null | undefined,
animationId: string,
opLabel: string,
): void {
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
if (!session || !animationId) return;
try {
recordAttempt(opLabel);
const elements = session.getElements();
const resolves =
elements.some((el) => el.animationIds.includes(animationId)) ||
session.getAllAnimationIds().has(animationId);
if (resolves) return; // SDK locates the animation — parity
trackStudioEvent("sdk_resolver_shadow", {
animationId,
opLabel,
sessionElementCount: elements.length,
mismatchCount: 1,
mismatches: JSON.stringify([
{ kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
]),
});
} catch {
// never propagate from the shadow path
}
}
// ─── Soak gate ────────────────────────────────────────────────────────────────
/**
* Evaluate the soak-gate exit criterion.
*
* A clean soak window has zero `element_not_found` divergences. When that
* condition holds, resolver parity is proven and the flag can be retired.
*/
export function evaluateSoakGate(divergenceCount: number): "parity-proven" | "divergence-detected" {
return divergenceCount === 0 ? "parity-proven" : "divergence-detected";
}
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
import { buildSlideshowIslandHtml } from "./setSlideshowManifest";
import { parseSlideshowManifest } from "@hyperframes/core/slideshow";
import type { CutoverDeps } from "./sdkCutover";
// Fix 3: vi.mock must be at module top level so Vitest can hoist them.
vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: true,
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
}));
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
describe("buildSlideshowIslandHtml", () => {
it("serializes a manifest into a script island", () => {
const html = buildSlideshowIslandHtml({ slides: [{ sceneId: "a" }] });
expect(html).toContain('type="application/hyperframes-slideshow+json"');
expect(html).toContain('"sceneId": "a"');
});
it("stamps version 1, preserving an existing version", () => {
expect(buildSlideshowIslandHtml({ slides: [] })).toContain('"version": 1');
expect(buildSlideshowIslandHtml({ version: 2, slides: [] })).toContain('"version": 2');
});
it("round-trips through parseSlideshowManifest", () => {
const html = `<html><body>${buildSlideshowIslandHtml({ slides: [{ sceneId: "x" }] })}</body></html>`;
const parsed = parseSlideshowManifest(html);
expect(parsed?.slides[0]?.sceneId).toBe("x");
});
it("wraps the JSON in a script tag with no extra nesting", () => {
const html = buildSlideshowIslandHtml({ slides: [] });
expect(html.startsWith("<script")).toBe(true);
expect(html.trimEnd().endsWith("</script>")).toBe(true);
});
// Fix 1: </script> breakout test
it("does NOT embed a literal </script> inside the JSON body", () => {
const manifest = { slides: [{ sceneId: "s1", notes: "</script><b>x</b>" }] };
const html = buildSlideshowIslandHtml(manifest);
// The only closing </script> should be the real one at the very end.
// Strip that trailing tag and confirm no </script> remains.
const withoutClosingTag = html.slice(0, html.lastIndexOf("</script>"));
expect(withoutClosingTag).not.toContain("</script>");
});
it("round-trips a manifest containing </script> in notes via parseSlideshowManifest", () => {
const notes = "</script><b>x</b>";
const manifest = { slides: [{ sceneId: "s1", notes }] };
const html = `<html><body>${buildSlideshowIslandHtml(manifest)}</body></html>`;
const parsed = parseSlideshowManifest(html);
expect(parsed?.slides[0]).toMatchObject({ sceneId: "s1", notes });
});
});
describe("persistSlideshowManifest — op construction", () => {
function makeDeps(writeProjectFile: ReturnType<typeof vi.fn>): CutoverDeps {
return {
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile,
reloadPreview: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
};
}
it("writes the serialized manifest when the island already exists", async () => {
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
const manifest = { slides: [{ sceneId: "scene-1" }] };
const island = buildSlideshowIslandHtml(manifest);
const originalHtml = `<html><head></head><body>${island}</body></html>`;
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
const deps = makeDeps(writeProjectFile);
const recordEdit = deps.editHistory.recordEdit as ReturnType<typeof vi.fn>;
const mockSession = { serialize: vi.fn().mockReturnValue(originalHtml) };
await persistSlideshowManifest({
manifest: { slides: [{ sceneId: "scene-2" }] },
sdkSession: mockSession as never,
originalContent: originalHtml,
targetPath: "/proj/comp.html",
deps,
});
expect(writeProjectFile).toHaveBeenCalledOnce();
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
expect(written).toContain('"sceneId": "scene-2"');
expect(recordEdit).toHaveBeenCalledWith(expect.objectContaining({ label: "Edit slideshow" }));
});
it("inserts the island when none exists in the serialized HTML", async () => {
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
const baseHtml = "<html><head></head><body></body></html>";
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
const deps = makeDeps(writeProjectFile);
const mockSession = { serialize: vi.fn().mockReturnValue(baseHtml) };
await persistSlideshowManifest({
manifest: { slides: [{ sceneId: "new-scene" }] },
sdkSession: mockSession as never,
originalContent: baseHtml,
targetPath: "/proj/comp.html",
deps,
});
expect(writeProjectFile).toHaveBeenCalledOnce();
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
expect(written).toContain('"sceneId": "new-scene"');
expect(written).toContain('type="application/hyperframes-slideshow+json"');
});
// Fix 2: two stale islands should collapse to exactly one after persist
it("collapses two stale islands into exactly one after persist", async () => {
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
const staleIsland1 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-1" }] });
const staleIsland2 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-2" }] });
const twoIslandHtml = `<html><head></head><body>${staleIsland1}${staleIsland2}</body></html>`;
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
const deps = makeDeps(writeProjectFile);
const mockSession = { serialize: vi.fn().mockReturnValue(twoIslandHtml) };
await persistSlideshowManifest({
manifest: { slides: [{ sceneId: "fresh" }] },
sdkSession: mockSession as never,
originalContent: twoIslandHtml,
targetPath: "/proj/comp.html",
deps,
});
expect(writeProjectFile).toHaveBeenCalledOnce();
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
// Count occurrences of the island script open tag
const islandCount = (written.match(/type="application\/hyperframes-slideshow\+json"/g) ?? [])
.length;
expect(islandCount).toBe(1);
expect(written).toContain('"sceneId": "fresh"');
expect(written).not.toContain('"sceneId": "old-1"');
expect(written).not.toContain('"sceneId": "old-2"');
});
});
@@ -0,0 +1,102 @@
/**
* setSlideshowManifest — Studio persist helper for the slideshow JSON island.
*
* The island is a `<script type="application/hyperframes-slideshow+json">` node
* embedded in the composition HTML. Because <script> nodes are not tracked by
* the SDK element tree (they have no hf-id), we cannot use a `setText` dispatch
* op. Instead we:
* 1. Call `sdkSession.serialize()` to get the current HTML.
* 2. Replace or insert the island with the new manifest JSON.
* 3. Write via `persistSdkSerialize` — the same low-level writer used by the
* other SDK-cutover paths (sdkDeletePersist, sdkTimingPersist, etc.).
*
* Inserting when absent: if no island exists in the serialized HTML we insert
* one before `</body>` (or, if no </body>, append to the end of the document).
* This means callers do NOT need to pre-scaffold the island; Task 11 / the panel
* can call persistSlideshowManifest on a fresh composition.
*/
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
import {
SLIDESHOW_ISLAND_TYPE,
SLIDESHOW_MANIFEST_VERSION,
parseSlideshowManifest,
slideshowIslandRegex,
} from "@hyperframes/core/slideshow";
import type { Composition } from "@hyperframes/sdk";
import type { CutoverDeps } from "./sdkCutover";
import { persistSdkSerialize } from "./sdkCutover";
// Matches ALL <script type="application/hyperframes-slideshow+json"> ... </script>
// blocks (global + case-insensitive) so we can strip every stale island in one pass.
const ISLAND_RE = slideshowIslandRegex("gi");
export function buildSlideshowIslandHtml(manifest: SlideshowManifest): string {
// Stamp the schema version (preserve an existing one) so future schema
// changes can detect and migrate older islands.
const versioned: SlideshowManifest = {
version: manifest.version ?? SLIDESHOW_MANIFEST_VERSION,
...manifest,
};
// Escape `<` and `>` so that a manifest field containing `</script>` cannot
// break out of the script tag. JSON.parse round-trips </> unchanged.
const json = JSON.stringify(versioned, null, 2).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
return `<script type="${SLIDESHOW_ISLAND_TYPE}">\n${json}\n</script>`;
}
export interface PersistSlideshowArgs {
manifest: SlideshowManifest;
/** Live SDK Composition session — used only to read the current serialized HTML. */
sdkSession: Pick<Composition, "serialize">;
/** Exact on-disk bytes for the undo-history `before` baseline. */
originalContent: string;
targetPath: string;
deps: CutoverDeps;
/** Optional label override (default: "Edit slideshow"). */
label?: string;
/**
* When provided, threads a coalesceKey into recordEdit so rapid writes
* (e.g. per-keystroke notes changes) collapse to a single undo entry.
*/
coalesceKey?: string;
}
export async function persistSlideshowManifest(args: PersistSlideshowArgs): Promise<void> {
const { manifest, sdkSession, originalContent, targetPath, deps, label, coalesceKey } = args;
const islandHtml = buildSlideshowIslandHtml(manifest);
// Write-time validation: confirm the island we just built round-trips to a
// valid manifest before touching disk, so a malformed edit can't corrupt the
// composition. parseSlideshowManifest throws on a structurally-invalid island.
try {
if (!parseSlideshowManifest(islandHtml)) {
throw new Error("built island did not parse back to a manifest");
}
} catch (err) {
throw new Error(`refusing to persist invalid slideshow manifest: ${(err as Error).message}`);
}
const current = sdkSession.serialize();
// Strip ALL existing islands (handles the case where two stale islands
// accumulated) then insert exactly one fresh island.
const stripped = current.replace(ISLAND_RE, "");
let after: string;
const bodyClose = stripped.lastIndexOf("</body>");
if (bodyClose !== -1) {
after = stripped.slice(0, bodyClose) + islandHtml + "\n" + stripped.slice(bodyClose);
} else {
after = stripped + "\n" + islandHtml;
}
// No-op gate: if the rewritten HTML is byte-identical to the current serialized
// HTML, skip the write — avoids a spurious disk write and a no-op undo entry.
if (after === current) return;
await persistSdkSerialize(after, targetPath, originalContent, deps, {
label: label ?? "Edit slideshow",
...(coalesceKey ? { coalesceKey } : {}),
});
}
@@ -0,0 +1,615 @@
import { describe, expect, it } from "vitest";
import {
applyPatch,
applyPatchByTarget,
readAttributeByTarget,
readTagSnippetByTarget,
type PatchOperation,
} from "./sourcePatcher";
describe("applyPatchByTarget", () => {
it("updates a composition host by data-composition-id selector", () => {
const html = `<div data-composition-id="intro" data-start="0" data-track-index="1"></div>`;
const op: PatchOperation = { type: "attribute", property: "start", value: "2.5" };
expect(applyPatchByTarget(html, { selector: '[data-composition-id="intro"]' }, op)).toContain(
'data-start="2.5"',
);
});
it("updates a class-based layer when the clip has no DOM id", () => {
const html = `<div class="headline clip" data-start="0" data-track-index="1"></div>`;
const op: PatchOperation = { type: "attribute", property: "track-index", value: "3" };
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toContain(
'data-track-index="3"',
);
});
it("removes a boolean data attribute by selector", () => {
const html = `<div class="headline clip" data-start="0" data-hidden></div>`;
const op: PatchOperation = { type: "attribute", property: "hidden", value: null };
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toBe(
`<div class="headline clip" data-start="0"></div>`,
);
});
it("updates inline z-index by selector when the clip has no DOM id", () => {
const html = `<div class="headline clip" style="position: absolute; opacity: 1" data-start="0"></div>`;
const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toContain(
'style="position: absolute; opacity: 1; z-index: 3"',
);
});
it("adds inline style to a self-closing void element without malforming it", () => {
const html = `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" />`;
const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
const result = applyPatch(html, "gif-img", op);
expect(result).toBe(
`<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" style="z-index: 3" />`,
);
expect(result).not.toContain("/ style");
});
it("adds inline style to a self-closing void element matched by selector", () => {
const html = `<img class="clip hero" data-start="0" src="bg.png" alt="" />`;
const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
const result = applyPatchByTarget(html, { selector: ".hero" }, op);
expect(result).toBe(
`<img class="clip hero" data-start="0" src="bg.png" alt="" style="opacity: 0.5" />`,
);
expect(result).not.toContain("/ style");
});
it("patches inline move styles by target", () => {
const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
const withLeft = applyPatchByTarget(
html,
{ id: "card" },
{ type: "inline-style", property: "left", value: "160px" },
);
const withTop = applyPatchByTarget(
withLeft,
{ id: "card" },
{ type: "inline-style", property: "top", value: "140px" },
);
expect(withTop).toContain('style="position: absolute; left: 160px; top: 140px"');
});
it("patches inline resize styles by target", () => {
const html = `<div id="card" style="position: absolute; width: 380px; height: 196px"></div>`;
const withWidth = applyPatchByTarget(
html,
{ id: "card" },
{ type: "inline-style", property: "width", value: "420px" },
);
const withHeight = applyPatchByTarget(
withWidth,
{ id: "card" },
{ type: "inline-style", property: "height", value: "220px" },
);
expect(withHeight).toContain('style="position: absolute; width: 420px; height: 220px"');
});
it("escapes quoted CSS urls inside double-quoted style attributes", () => {
const html = `<div id="card" style="position: absolute; opacity: 1"></div>`;
const withBackground = applyPatchByTarget(
html,
{ id: "card" },
{
type: "inline-style",
property: "background-image",
value: `url("../ChatGPT Image Apr 22, 2026.png")`,
},
);
const withRadius = applyPatchByTarget(
withBackground,
{ id: "card" },
{ type: "inline-style", property: "border-radius", value: "12px" },
);
expect(withRadius).toContain(
"background-image: url(&quot;../ChatGPT Image Apr 22, 2026.png&quot;)",
);
expect(withRadius).toContain("border-radius: 12px");
});
it("updates media timing attributes by selector", () => {
const html = `<video class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></video>`;
const withDuration = applyPatchByTarget(
html,
{ selector: ".hero" },
{
type: "attribute",
property: "duration",
value: "1.1",
},
);
const withMediaStart = applyPatchByTarget(
withDuration,
{ selector: ".hero" },
{
type: "attribute",
property: "media-start",
value: "0.7",
},
);
expect(withMediaStart).toContain('data-duration="1.1"');
expect(withMediaStart).toContain('data-media-start="0.7"');
});
it("reads media timing attributes by selector", () => {
const html = `<div class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></div>`;
expect(readAttributeByTarget(html, { selector: ".hero" }, "media-start")).toBe("0.4");
expect(readAttributeByTarget(html, { selector: ".hero" }, "duration")).toBe("1.4");
});
it("reads the matched tag snippet by target", () => {
const html = `<section id="hero" class="card clip" style="left: 120px; top: 180px"></section>`;
expect(readTagSnippetByTarget(html, { id: "hero" })).toBe(
`<section id="hero" class="card clip" style="left: 120px; top: 180px"`,
);
});
it("patches and reads single-quoted attributes and styles", () => {
const html =
"<section id='hero' class='card clip' data-start='0.2' style='left: 120px; top: 180px'></section>";
const moved = applyPatchByTarget(
html,
{ id: "hero" },
{ type: "inline-style", property: "left", value: "160px" },
);
const updated = applyPatchByTarget(
moved,
{ id: "hero" },
{ type: "attribute", property: "start", value: "0.4" },
);
expect(updated).toContain(`style='left: 160px; top: 180px'`);
expect(updated).toContain(`data-start="0.4"`);
expect(readAttributeByTarget(updated, { id: "hero" }, "start")).toBe("0.4");
});
it("replaces the full text body of a nested element by id", () => {
const html =
'<div id="panel"><strong>Headline</strong><span>Supporting copy</span></div><p>Outside</p>';
const patched = applyPatch(html, "panel", {
type: "text-content",
property: "text",
value: "<strong>New headline</strong><span>New supporting copy</span>",
});
expect(patched).toContain(
'<div id="panel"><strong>New headline</strong><span>New supporting copy</span></div>',
);
expect(patched).toContain("<p>Outside</p>");
});
it("does not stop at the first child closing tag when patching nested text", () => {
const html =
'<section id="card"><div><strong>Headline</strong></div><div>Copy</div></section><p>Outside</p>';
const patched = applyPatchByTarget(
html,
{ id: "card" },
{
type: "text-content",
property: "text",
value: "<strong>New headline</strong>",
},
);
expect(patched).toBe(
'<section id="card"><strong>New headline</strong></section><p>Outside</p>',
);
});
it("patches the correct duplicate selector occurrence", () => {
const html = [
`<div class="headline clip" data-start="0"></div>`,
`<div class="headline clip" data-start="1"></div>`,
].join("");
const patched = applyPatchByTarget(
html,
{ selector: ".headline", selectorIndex: 1 },
{
type: "attribute",
property: "start",
value: "2.5",
},
);
expect(patched).toContain(`<div class="headline clip" data-start="0"></div>`);
expect(patched).toContain(`<div class="headline clip" data-start="2.5"></div>`);
});
it("escapes JSON attribute values containing double-quotes and round-trips them", () => {
const html = `<div id="card" data-start="0"></div>`;
const motionJson = JSON.stringify({ preset: "fadeIn", start: 0, duration: 1.5 });
const patched = applyPatch(html, "card", {
type: "attribute",
property: "data-hf-studio-motion",
value: motionJson,
});
// The raw HTML must NOT contain unescaped quotes inside the attribute
expect(patched).not.toMatch(/data-hf-studio-motion="[^"]*"[^"]*"/);
// Entities should be present
expect(patched).toContain("&quot;");
// Reading the attribute back should return the original JSON
const readBack = readAttributeByTarget(patched, { id: "card" }, "data-hf-studio-motion");
expect(readBack).toBe(motionJson);
});
it("escapes and round-trips data-hf-studio-motion-original-transform with quotes", () => {
const html = `<div id="hero" data-start="0"></div>`;
const transform = `rotate(15deg) translate("50px", "100px")`;
const patched = applyPatchByTarget(
html,
{ id: "hero" },
{
type: "attribute",
property: "data-hf-studio-motion-original-transform",
value: transform,
},
);
// No broken attribute boundary
expect(patched).not.toMatch(/data-hf-studio-motion-original-transform="[^"]*"[^"]*"/);
const readBack = readAttributeByTarget(
patched,
{ id: "hero" },
"data-hf-studio-motion-original-transform",
);
expect(readBack).toBe(transform);
});
it("escapes ampersands and angle brackets in attribute values", () => {
const html = `<div id="el" data-start="0"></div>`;
const value = `a&b<c>d"e`;
const patched = applyPatch(html, "el", {
type: "attribute",
property: "data-custom",
value,
});
expect(patched).toContain("a&amp;b&lt;c&gt;d&quot;e");
const readBack = readAttributeByTarget(patched, { id: "el" }, "data-custom");
expect(readBack).toBe(value);
});
it("updates an already-escaped attribute value to a new escaped value", () => {
const html = `<div id="card" data-start="0"></div>`;
const first = JSON.stringify({ preset: "fadeIn" });
const second = JSON.stringify({ preset: "slideUp", easing: "ease-out" });
const patched1 = applyPatch(html, "card", {
type: "attribute",
property: "data-hf-studio-motion",
value: first,
});
const patched2 = applyPatch(patched1, "card", {
type: "attribute",
property: "data-hf-studio-motion",
value: second,
});
const readBack = readAttributeByTarget(patched2, { id: "card" }, "data-hf-studio-motion");
expect(readBack).toBe(second);
});
});
describe("motion attribute round-trip via sourcePatcher", () => {
it("round-trips data-hf-studio-motion JSON through patch and read", () => {
const html = `<div id="hero" style="position: absolute">Hero</div>`;
const motion = {
start: 0.5,
duration: 1,
ease: "power3.out",
from: { opacity: 0, y: 40 },
to: { opacity: 1, y: 0 },
};
const motionJson = JSON.stringify(motion);
const patched = applyPatchByTarget(
html,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion", value: motionJson },
);
const readBack = readAttributeByTarget(patched, { id: "hero" }, "data-hf-studio-motion");
expect(readBack).toBeDefined();
expect(JSON.parse(readBack!)).toEqual(motion);
});
it("round-trips motion with customEase containing SVG path data", () => {
const html = `<div id="card" class="clip" data-start="0" data-duration="10">Card</div>`;
const motion = {
start: 0.25,
duration: 0.8,
ease: "studio-card-bounce",
customEase: { id: "studio-card-bounce", data: "M0,0 C0.18,0.9 0.32,1 1,1" },
from: { y: 44, autoAlpha: 0 },
to: { y: 0, autoAlpha: 1 },
};
const motionJson = JSON.stringify(motion);
const patched = applyPatchByTarget(
html,
{ id: "card" },
{ type: "attribute", property: "data-hf-studio-motion", value: motionJson },
);
const readBack = readAttributeByTarget(patched, { id: "card" }, "data-hf-studio-motion");
expect(readBack).toBeDefined();
expect(JSON.parse(readBack!)).toEqual(motion);
});
it("round-trips all four motion attributes (motion + three originals)", () => {
const html = `<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible">Hero</div>`;
const motion = {
start: 0,
duration: 0.6,
ease: "power2.out",
from: { autoAlpha: 0, y: 32 },
// fallow-ignore-next-line code-duplication
to: { autoAlpha: 1, y: 0 },
};
let result = html;
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{
type: "attribute",
property: "data-hf-studio-motion-original-transform",
value: "rotate(5deg)",
},
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion-original-opacity", value: "0.8" },
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{
type: "attribute",
property: "data-hf-studio-motion-original-visibility",
value: "visible",
},
);
expect(
JSON.parse(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")!),
).toEqual(motion);
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
).toBe("rotate(5deg)");
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
).toBe("0.8");
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
).toBe("visible");
});
it("removes all four motion attributes when clearing", () => {
const html = `<div id="hero" style="position: absolute">Hero</div>`;
const motion = {
start: 0,
duration: 1,
ease: "none",
from: { opacity: 0 },
// fallow-ignore-next-line code-duplication
to: { opacity: 1 },
};
let result = html;
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion-original-transform", value: "" },
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion-original-opacity", value: "1" },
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion-original-visibility", value: "" },
);
// Verify all four attributes exist
expect(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")).toBeDefined();
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
).toBeDefined();
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
).toBeDefined();
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
).toBeDefined();
// Remove all four
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion", value: null },
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion-original-transform", value: null },
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion-original-opacity", value: null },
);
result = applyPatchByTarget(
result,
{ id: "hero" },
{ type: "attribute", property: "data-hf-studio-motion-original-visibility", value: null },
);
expect(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")).toBeUndefined();
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
).toBeUndefined();
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
).toBeUndefined();
expect(
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
).toBeUndefined();
});
it("round-trips motion via selector when element has no id", () => {
const html = `<div class="headline clip" style="position: absolute">Title</div>`;
const motion = {
start: 0.3,
duration: 0.5,
ease: "sine.out",
from: { scale: 0.88, autoAlpha: 0 },
to: { scale: 1, autoAlpha: 1 },
};
const patched = applyPatchByTarget(
html,
{ selector: ".headline" },
{ type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
);
const readBack = readAttributeByTarget(
patched,
{ selector: ".headline" },
"data-hf-studio-motion",
);
expect(readBack).toBeDefined();
expect(JSON.parse(readBack!)).toEqual(motion);
});
});
// T3 — id-based targeting (R1).
describe("T3 — hfId targeting (spec for R1)", () => {
it("updates inline style by data-hf-id", () => {
const html = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
const result = applyPatchByTarget(
html,
{ hfId: "hf-x7k2" },
{
type: "inline-style",
property: "color",
value: "blue",
},
);
expect(result).toContain("color: blue");
expect(result).toContain('data-hf-id="hf-x7k2"');
});
it("updates text content by data-hf-id", () => {
const html = `<p data-hf-id="hf-a1b2">Old text</p>`;
const result = applyPatchByTarget(
html,
{ hfId: "hf-a1b2" },
{
type: "text-content",
property: "",
value: "New text",
},
);
expect(result).toContain(">New text<");
});
it("updates attribute by data-hf-id", () => {
const html = `<div data-hf-id="hf-c3d4" data-start="0"></div>`;
const result = applyPatchByTarget(
html,
{ hfId: "hf-c3d4" },
{
type: "attribute",
property: "start",
value: "2.5",
},
);
expect(result).toContain('data-start="2.5"');
});
it("data-hf-id attribute is preserved after a style patch", () => {
const html = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
const patched = applyPatchByTarget(
html,
{ hfId: "hf-x7k2" },
{
type: "inline-style",
property: "color",
value: "blue",
},
);
expect(readAttributeByTarget(patched, { hfId: "hf-x7k2" }, "data-hf-id")).toBe("hf-x7k2");
});
it("hfId lookup falls through to selector when hfId not found", () => {
const html = `<h1 class="headline" style="color: red">Hello</h1>`;
const result = applyPatchByTarget(
html,
{ hfId: "hf-missing", selector: ".headline" },
{ type: "inline-style", property: "color", value: "blue" },
);
expect(result).toContain("color: blue");
});
it("hfId match is authoritative — selector is not used as a narrowing filter", () => {
// hfId matches h1; selector points at h2. hfId wins — patch lands on h1, h2 untouched.
const html = `<h1 data-hf-id="hf-x7k2" class="a">A</h1><h2 class="b">B</h2>`;
const result = applyPatchByTarget(
html,
{ hfId: "hf-x7k2", selector: ".b" },
{ type: "inline-style", property: "color", value: "blue" },
);
expect(result).toContain('data-hf-id="hf-x7k2"');
const h1End = result.indexOf("</h1>");
const bluePos = result.indexOf("color: blue");
expect(bluePos).toBeGreaterThan(-1);
expect(bluePos).toBeLessThan(h1End);
expect(result).toContain('<h2 class="b">B</h2>');
});
});
+556
View File
@@ -0,0 +1,556 @@
/**
* Source Patcher — Maps visual property edits back to source HTML files.
* Handles inline style updates, attribute changes, and text content.
*/
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeStyleAttributeValue(value: string, quote: string): string {
return quote === '"' ? value.replace(/"/g, "&quot;") : value.replace(/'/g, "&#39;");
}
/** Escape a string for safe use inside a double-quoted HTML attribute. */
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** Reverse escapeHtmlAttribute so callers get the original value. */
function unescapeHtmlAttribute(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}
function splitInlineStyleDeclarations(style: string): string[] {
const declarations: string[] = [];
let current = "";
let quote: string | null = null;
let entity = false;
let parenDepth = 0;
for (const char of style) {
if (entity) {
current += char;
if (char === ";") entity = false;
continue;
}
if (char === "&") {
entity = true;
current += char;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === "(") {
parenDepth += 1;
current += char;
continue;
}
if (char === ")") {
parenDepth = Math.max(0, parenDepth - 1);
current += char;
continue;
}
if (char === ";" && parenDepth === 0) {
declarations.push(current);
current = "";
continue;
}
current += char;
}
if (current) declarations.push(current);
return declarations;
}
export interface PatchOperation {
type: "inline-style" | "attribute" | "text-content" | "html-attribute";
property: string;
value: string | null;
childSelector?: string;
childIndex?: number;
}
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
// escape). This type is documentation only; the server's MutationTarget mirrors this shape.
export interface PatchTarget {
id?: string | null;
hfId?: string;
selector?: string;
selectorIndex?: number;
}
/**
* Find which source file contains an element by its ID.
*/
export function resolveSourceFile(
elementId: string | null,
selector: string,
files: Record<string, string>,
): string | null {
if (!elementId && !selector) return null;
// Strategy 1: Search by id attribute
if (elementId) {
for (const [path, content] of Object.entries(files)) {
if (content.includes(`id="${elementId}"`) || content.includes(`id='${elementId}'`)) {
return path;
}
}
}
// Strategy 2: Search by data-composition-id from the selector
const compIdMatch = selector.match(/data-composition-id="([^"]+)"/);
if (compIdMatch) {
const compId = compIdMatch[1];
for (const [path, content] of Object.entries(files)) {
if (content.includes(`data-composition-id="${compId}"`)) {
return path;
}
}
}
// Strategy 3: Search by class from the selector
const classMatch = selector.match(/^\.([a-zA-Z0-9_-]+)/);
if (classMatch) {
const cls = classMatch[1];
for (const [path, content] of Object.entries(files)) {
if (
content.includes(`class="${cls}"`) ||
content.includes(`class="${cls} `) ||
content.includes(` ${cls}"`)
) {
return path;
}
}
}
// Fallback: index.html
if ("index.html" in files) return "index.html";
return null;
}
/**
* Apply a style property change to an element's inline style in the HTML source.
*/
function patchInlineStyle(
html: string,
elementId: string,
prop: string,
value: string | null,
): string {
// Find the element tag with this id
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
if (!match) return html;
const tag = match[1];
return patchInlineStyleInTag(html, tag, prop, value);
}
function patchInlineStyleInTag(
html: string,
tag: string,
prop: string,
value: string | null,
): string {
if (!tag) return html;
// Check if there's an existing style attribute
const styleMatch = /\bstyle=(["'])([\s\S]*?)\1/.exec(tag);
if (styleMatch) {
const existingStyle = styleMatch[2];
const quote = styleMatch[1];
// Parse existing properties
const props = new Map<string, string>();
for (const part of splitInlineStyleDeclarations(existingStyle)) {
const colon = part.indexOf(":");
if (colon < 0) continue;
const key = part.slice(0, colon).trim();
const val = part.slice(colon + 1).trim();
if (key) props.set(key, val);
}
// Update/add or remove the property
if (value === null) {
props.delete(prop);
} else {
props.set(prop, value);
}
// Rebuild style string; keep style="" if empty (harmless)
const newStyle = Array.from(props.entries())
.map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
.join("; ");
const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
return html.replace(tag, newTag);
} else {
// No existing style attribute
if (value === null) return html; // nothing to remove
const selfClosing = /\s*\/$/.test(tag);
const base = selfClosing ? tag.replace(/\s*\/$/, "") : tag;
const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
return html.replace(tag, newTag);
}
}
function patchInlineStyleByTarget(
html: string,
target: PatchTarget,
prop: string,
value: string | null,
): string {
const match = findTagByTarget(html, target);
if (!match) return html;
const newTag = patchInlineStyleInTag(match.tag, match.tag, prop, value);
return replaceTagAtMatch(html, match, newTag);
}
interface TagMatch {
tag: string;
start: number;
end: number;
}
function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): string {
return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
}
function execDataAttrPattern(html: string, attr: string, value: string): TagMatch | null {
const pattern = new RegExp(`(<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\2[^>]*)>`, "i");
const match = pattern.exec(html);
if (match?.index == null) return null;
return { tag: match[1], start: match.index, end: match.index + match[1].length };
}
function findTagByClass(html: string, target: PatchTarget): TagMatch | null {
const classMatch = target.selector?.match(/^\.([a-zA-Z0-9_-]+)$/);
if (!classMatch) return null;
const cls = classMatch[1];
const pattern = new RegExp(
`(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
"gi",
);
const selectorIndex = target.selectorIndex ?? 0;
let match: RegExpExecArray | null;
let currentIndex = 0;
while ((match = pattern.exec(html)) !== null) {
if (currentIndex === selectorIndex && match.index != null) {
return {
tag: match[1],
start: match.index,
end: match.index + match[1].length,
};
}
currentIndex += 1;
}
return null;
}
export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
if (target.hfId) {
const result = execDataAttrPattern(html, "data-hf-id", target.hfId);
if (result) return result;
}
if (target.id) {
const result = execDataAttrPattern(html, "id", target.id);
if (result) return result;
}
if (!target.selector) return null;
const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
if (compositionIdMatch) {
const result = execDataAttrPattern(html, "data-composition-id", compositionIdMatch[1]);
if (result) return result;
}
return findTagByClass(html, target);
}
export function readAttributeByTarget(
html: string,
target: PatchTarget,
attr: string,
): string | undefined {
const match = findTagByTarget(html, target);
if (!match) return undefined;
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
return valueMatch?.[2] != null ? unescapeHtmlAttribute(valueMatch[2]) : undefined;
}
export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
const match = findTagByTarget(html, target);
return match?.tag;
}
function patchAttributeByTarget(
html: string,
target: PatchTarget,
attr: string,
value: string | null,
): string {
const match = findTagByTarget(html, target);
if (!match) return html;
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
const tag = match.tag;
if (value === null) {
// Remove the attribute if present
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
if (!boolAttrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
const newTag = tag.replace(removePattern, "");
return replaceTagAtMatch(html, match, newTag);
}
const escaped = escapeHtmlAttribute(value);
if (attrPattern.test(tag)) {
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
return replaceTagAtMatch(html, match, newTag);
}
const newTag = tag + ` ${fullAttr}="${escaped}"`;
return replaceTagAtMatch(html, match, newTag);
}
/**
* Apply an attribute change to an element in the HTML source.
*/
function patchAttribute(
html: string,
elementId: string,
attr: string,
value: string | null,
): string {
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
if (!match) return html;
const tag = match[1];
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
if (value === null) {
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
if (!boolAttrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
const newTag = tag.replace(removePattern, "");
return html.replace(tag, newTag);
}
const escaped = escapeHtmlAttribute(value);
if (attrPattern.test(tag)) {
// Update existing attribute
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
return html.replace(tag, newTag);
} else {
// Add new attribute
const newTag = tag + ` ${fullAttr}="${escaped}"`;
return html.replace(tag, newTag);
}
}
/**
* Apply a text content change to an element.
*/
function patchTextContent(html: string, elementId: string, value: string): string {
const openTagPattern = new RegExp(
`(<([a-z0-9-]+)[^>]*\\bid=(["'])${escapeRegex(elementId)}\\3[^>]*>)`,
"i",
);
const match = openTagPattern.exec(html);
if (!match || match.index == null) return html;
const openingTag = match[1];
const tagName = match[2];
const contentStart = match.index + openingTag.length;
const closingIndex = findMatchingClosingTagIndex(html, tagName, contentStart);
if (closingIndex < 0) return html;
return `${html.slice(0, contentStart)}${value}${html.slice(closingIndex)}`;
}
function findMatchingClosingTagIndex(html: string, tagName: string, contentStart: number): number {
const tagPattern = new RegExp(`</?${escapeRegex(tagName)}\\b[^>]*>`, "gi");
tagPattern.lastIndex = contentStart;
let depth = 1;
let match: RegExpExecArray | null;
while ((match = tagPattern.exec(html)) !== null) {
const tag = match[0];
if (tag.startsWith("</")) {
depth -= 1;
if (depth === 0) return match.index;
continue;
}
if (!tag.endsWith("/>")) depth += 1;
}
return -1;
}
const HTML_BOOLEAN_ATTRIBUTES = new Set([
"loop",
"muted",
"autoplay",
"playsinline",
"controls",
"default",
"defer",
"disabled",
"hidden",
"nomodule",
"open",
"readonly",
"required",
"reversed",
"selected",
]);
function patchHtmlAttributeInTag(
html: string,
tag: string,
attr: string,
value: string | null,
): string {
if (!tag) return html;
const isBoolean = HTML_BOOLEAN_ATTRIBUTES.has(attr);
if (isBoolean) {
const escapedAttr = escapeRegex(attr);
const hasBoolAttr = new RegExp(`(?:^|\\s)${escapedAttr}(?:\\s|=|$)`).test(tag);
if (value === null || value === "" || value === "false") {
if (!hasBoolAttr) return html;
const removePattern = new RegExp(`\\s+${escapedAttr}(?:=(["'])[^"']*\\1)?`);
const newTag = tag.replace(removePattern, "");
return html.replace(tag, newTag);
}
if (hasBoolAttr) return html;
const newTag = tag + ` ${attr}`;
return html.replace(tag, newTag);
}
const attrPattern = new RegExp(`\\b${escapeRegex(attr)}=(["'])([^"']*)\\1`);
if (value === null) {
if (!attrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(attr)}=(["'])[^"']*\\1`);
const newTag = tag.replace(removePattern, "");
return html.replace(tag, newTag);
}
const escaped = escapeHtmlAttribute(value);
if (attrPattern.test(tag)) {
const newTag = tag.replace(attrPattern, `${attr}="${escaped}"`);
return html.replace(tag, newTag);
}
const newTag = tag + ` ${attr}="${escaped}"`;
return html.replace(tag, newTag);
}
function patchHtmlAttribute(
html: string,
elementId: string,
attr: string,
value: string | null,
): string {
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
if (!match) return html;
return patchHtmlAttributeInTag(html, match[1], attr, value);
}
function patchHtmlAttributeByTarget(
html: string,
target: PatchTarget,
attr: string,
value: string | null,
): string {
const match = findTagByTarget(html, target);
if (!match) return html;
const newTag = patchHtmlAttributeInTag(match.tag, match.tag, attr, value);
return replaceTagAtMatch(html, match, newTag);
}
function patchTextContentByTarget(html: string, target: PatchTarget, value: string): string {
const match = findTagByTarget(html, target);
if (!match) return html;
const tagNameMatch = /^<([a-z0-9-]+)/i.exec(match.tag);
const tagName = tagNameMatch?.[1];
if (!tagName) return html;
const closingIndex = findMatchingClosingTagIndex(html, tagName, match.end + 1);
if (closingIndex < 0) return html;
return `${html.slice(0, match.end + 1)}${value}${html.slice(closingIndex)}`;
}
/**
* Apply a patch operation to an HTML source file.
*/
export function applyPatch(html: string, elementId: string, op: PatchOperation): string {
switch (op.type) {
case "inline-style":
return patchInlineStyle(html, elementId, op.property, op.value);
case "attribute":
return patchAttribute(html, elementId, op.property, op.value);
case "html-attribute":
return patchHtmlAttribute(html, elementId, op.property, op.value);
case "text-content":
return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
default:
return html;
}
}
export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchOperation): string {
if (target.id) {
const patchedById = applyPatch(html, target.id, op);
if (patchedById !== html || !target.selector) {
return patchedById;
}
}
switch (op.type) {
case "inline-style":
return patchInlineStyleByTarget(html, target, op.property, op.value);
case "attribute":
return patchAttributeByTarget(html, target, op.property, op.value);
case "html-attribute":
return patchHtmlAttributeByTarget(html, target, op.property, op.value);
case "text-content":
return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
default:
return html;
}
}
@@ -0,0 +1,156 @@
import { describe, expect, it, vi } from "vitest";
import { saveProjectFilesWithHistory } from "./studioFileHistory";
describe("saveProjectFilesWithHistory", () => {
it("reads before content, writes after content, and records a history entry", async () => {
const reads: Record<string, string> = { "index.html": "before" };
const writes: Record<string, string> = {};
const recordEdit = vi.fn();
await saveProjectFilesWithHistory({
projectId: "project-1",
label: "Move layer",
kind: "manual",
files: { "index.html": "after" },
readFile: async (path) => reads[path],
writeFile: async (path, content) => {
writes[path] = content;
},
recordEdit,
});
expect(writes).toEqual({ "index.html": "after" });
expect(recordEdit).toHaveBeenCalledWith({
label: "Move layer",
kind: "manual",
coalesceKey: undefined,
files: { "index.html": { before: "before", after: "after" } },
});
});
it("skips writes and history for unchanged content", async () => {
const writeFile = vi.fn();
const recordEdit = vi.fn();
const changedPaths = await saveProjectFilesWithHistory({
projectId: "project-1",
label: "Edit layer",
kind: "manual",
files: { "index.html": "same" },
readFile: async () => "same",
writeFile,
recordEdit,
});
expect(changedPaths).toEqual([]);
expect(writeFile).not.toHaveBeenCalled();
expect(recordEdit).not.toHaveBeenCalled();
});
it("rolls back files already written when a later file write fails", async () => {
const reads: Record<string, string> = {
"index.html": "index-before",
"scene.html": "scene-before",
};
const writes: Array<[string, string]> = [];
const recordEdit = vi.fn();
await expect(
saveProjectFilesWithHistory({
projectId: "project-1",
label: "Move layer",
kind: "manual",
files: {
"index.html": "index-after",
"scene.html": "scene-after",
},
readFile: async (path) => reads[path],
writeFile: async (path, content) => {
writes.push([path, content]);
if (path === "scene.html") {
throw new Error("disk full");
}
},
recordEdit,
}),
).rejects.toThrow("disk full");
expect(writes).toEqual([
["index.html", "index-after"],
["scene.html", "scene-after"],
["index.html", "index-before"],
]);
expect(recordEdit).not.toHaveBeenCalled();
});
it("rolls back written files when the injected history recorder throws", async () => {
const reads: Record<string, string> = {
"index.html": "index-before",
"scene.html": "scene-before",
};
const writes: Array<[string, string]> = [];
await expect(
saveProjectFilesWithHistory({
projectId: "project-1",
label: "Move layer",
kind: "manual",
files: {
"index.html": "index-after",
"scene.html": "scene-after",
},
readFile: async (path) => reads[path],
writeFile: async (path, content) => {
writes.push([path, content]);
},
recordEdit: async () => {
throw new Error("history unavailable");
},
}),
).rejects.toThrow("history unavailable");
expect(writes).toEqual([
["index.html", "index-after"],
["scene.html", "scene-after"],
["scene.html", "scene-before"],
["index.html", "index-before"],
]);
});
it("reports rollback failure with the original write failure", async () => {
const reads: Record<string, string> = {
"index.html": "index-before",
"scene.html": "scene-before",
};
const writes: Array<[string, string]> = [];
await expect(
saveProjectFilesWithHistory({
projectId: "project-1",
label: "Move layer",
kind: "manual",
files: {
"index.html": "index-after",
"scene.html": "scene-after",
},
readFile: async (path) => reads[path],
writeFile: async (path, content) => {
writes.push([path, content]);
if (path === "scene.html" && content === "scene-after") {
throw new Error("write denied");
}
if (path === "index.html" && content === "index-before") {
throw new Error("rollback denied");
}
},
recordEdit: vi.fn(),
}),
).rejects.toThrow("rollback did not complete");
expect(writes).toEqual([
["index.html", "index-after"],
["scene.html", "scene-after"],
["index.html", "index-before"],
]);
});
});
@@ -0,0 +1,61 @@
import type { EditHistoryKind } from "./editHistory";
interface SaveProjectFilesWithHistoryInput {
projectId: string;
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, string>;
readFile: (path: string) => Promise<string>;
writeFile: (path: string, content: string) => Promise<void>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
}
export async function saveProjectFilesWithHistory({
label,
kind,
coalesceKey,
files,
readFile,
writeFile,
recordEdit,
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
const snapshots: Record<string, { before: string; after: string }> = {};
for (const [path, after] of Object.entries(files)) {
const before = await readFile(path);
if (before !== after) {
snapshots[path] = { before, after };
}
}
const changedPaths = Object.keys(snapshots);
if (changedPaths.length === 0) return [];
const writtenPaths: string[] = [];
try {
for (const path of changedPaths) {
await writeFile(path, snapshots[path].after);
writtenPaths.push(path);
}
await recordEdit({ label, kind, coalesceKey, files: snapshots });
} catch (error) {
try {
for (const path of writtenPaths.reverse()) {
await writeFile(path, snapshots[path].before);
}
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
"Failed to save project files and rollback did not complete",
);
}
throw error;
}
return changedPaths;
}
@@ -0,0 +1,83 @@
import { googleFontStylesheetUrl } from "../components/editor/fontCatalog";
import { importedFontFaceCss, type ImportedFontAsset } from "../components/editor/fontAssets";
import { toRelativeProjectAssetPath } from "./studioHelpers";
const GENERIC_FONT_FAMILIES = new Set([
"inherit",
"initial",
"revert",
"revert-layer",
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-sans-serif",
"ui-serif",
"ui-monospace",
"ui-rounded",
"emoji",
"math",
"fangsong",
]);
function primaryFontFamilyFromCss(value: string): string {
const first = value.split(",")[0] ?? "";
return first.trim().replace(/^["']|["']$/g, "");
}
export function primaryFontFamilyValue(value: string): string {
return (
value
.split(",")[0]
?.trim()
.replace(/^["']|["']$/g, "")
.trim() ?? ""
);
}
export function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
const family = primaryFontFamilyFromCss(fontFamilyValue);
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
if (doc.getElementById(id)) return;
const link = doc.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = googleFontStylesheetUrl(family);
doc.head.appendChild(link);
}
export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
if (doc.getElementById(id)) return;
const style = doc.createElement("style");
style.id = id;
style.textContent = importedFontFaceCss(asset);
doc.head.appendChild(style);
}
export function ensureImportedFontFace(
html: string,
asset: ImportedFontAsset,
sourceFile: string,
): string {
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
if (html.includes(css)) return html;
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
const styleMatch = styleRe.exec(html);
if (styleMatch) {
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
}
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
if (/<\/head>/i.test(html)) {
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
}
return `${styleTag}\n${html}`;
}
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
findMatchingTimelineElementId,
findTimelineIdByAncestor,
resolveTimelineIdForSelection,
resolveTimelineSelectionSeekTime,
} from "./studioHelpers";
describe("resolveTimelineSelectionSeekTime", () => {
it("keeps the current time when it is already inside the clip range", () => {
expect(resolveTimelineSelectionSeekTime(3, { start: 0, duration: 5 })).toBe(3);
});
it("clamps to the clip start when current time is before the clip", () => {
expect(resolveTimelineSelectionSeekTime(1, { start: 4, duration: 3 })).toBe(4);
});
it("clamps to the clip end when current time is after the clip", () => {
expect(resolveTimelineSelectionSeekTime(10, { start: 4, duration: 3 })).toBe(7);
});
it("falls back to the clip start for invalid current time", () => {
expect(resolveTimelineSelectionSeekTime(Number.NaN, { start: 2, duration: 5 })).toBe(2);
});
});
describe("findMatchingTimelineElementId", () => {
const el = (over: Record<string, unknown>) =>
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
it("matches a top-level element by domId + sourceFile", () => {
const els = [el({ id: "s1", domId: "s1", sourceFile: "index.html" })];
expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
});
it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
expect(
findMatchingTimelineElementId(
{ id: "stat-3", sourceFile: "compositions/stats-panel.html" },
els,
),
).toBe("compositions/stats-panel.html#stat-3");
});
it("returns null for an unmatched element in index.html", () => {
expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
});
});
describe("findTimelineIdByAncestor", () => {
const el = (over: Record<string, unknown>) =>
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
it("resolves a static descendant (.num) to its nearest clip ancestor", () => {
// #stat1 (a clip) > .num (selected, not a clip)
const stat1 = document.createElement("div");
stat1.id = "stat1";
const num = document.createElement("div");
num.className = "num";
stat1.appendChild(num);
const els = [el({ id: "stat1", domId: "stat1", key: "index.html#stat1" })];
expect(findTimelineIdByAncestor(num, els, "index.html")).toBe("index.html#stat1");
});
it("returns null when no ancestor is a clip", () => {
const wrap = document.createElement("div");
const child = document.createElement("span");
wrap.appendChild(child);
expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
});
});
describe("resolveTimelineIdForSelection", () => {
const el = (over: Record<string, unknown>) =>
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
it("resolves an ancestor clip against activeCompPath when the selection has no sourceFile", () => {
// #card (a clip in a sub-composition) > .leaf (selected, not itself a clip)
const card = document.createElement("div");
card.id = "card";
const leaf = document.createElement("span");
leaf.className = "leaf";
card.appendChild(leaf);
const els = [
el({
id: "card",
domId: "card",
key: "comps/panel.html#card",
sourceFile: "comps/panel.html",
}),
];
const selection = { element: leaf } as never;
// Falling back to the active comp matches; the old index.html-only fallback would miss.
expect(resolveTimelineIdForSelection(selection, els, "comps/panel.html")).toBe(
"comps/panel.html#card",
);
expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
});
});
+310
View File
@@ -0,0 +1,310 @@
import type { TimelineElement } from "../player/store/playerStore";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { TimelineAssetKind } from "./timelineAssetDrop";
import { roundToCenti } from "./rounding";
export interface EditingFile {
path: string;
content: string | null;
}
export interface AppToast {
message: string;
tone: "error" | "info";
}
export type RightPanelTab =
| "layers"
| "design"
| "renders"
| "block-params"
| "slideshow"
| "variables";
export type RightInspectorPane = "layers" | "design";
export interface RightInspectorPanes {
layers: boolean;
design: boolean;
}
export interface AgentModalAnchorPoint {
x: number;
y: number;
}
export function getTimelineElementLabel(element: TimelineElement): string {
return element.label || element.id || element.tag;
}
function normalizeProjectAssetPath(value: string): string {
const trimmed = value.trim();
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
return decodeURIComponent(maybeUrl)
.replace(/\\/g, "/")
.replace(/^\.?\//, "");
}
export function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
fromParts.pop();
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
fromParts.shift();
targetParts.shift();
}
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
}
function isAbsoluteFilePath(value: string): boolean {
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
}
export function toProjectAbsolutePath(
projectDir: string | null,
sourceFile: string,
): string | undefined {
const trimmedSource = sourceFile.trim();
if (!trimmedSource) return undefined;
const normalizedSource = trimmedSource.replace(/\\/g, "/");
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalizedRoot) return undefined;
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
}
export function normalizeDomEditStyleValue(property: string, value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
if (
["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
/^-?\d+(\.\d+)?$/.test(trimmed)
) {
return `${trimmed}px`;
}
return trimmed;
}
export function isImageBackgroundValue(value: string): boolean {
return /^url\(/i.test(value.trim());
}
export function isManualGeometryStyleProperty(property: string): boolean {
return property === "left" || property === "top" || property === "width" || property === "height";
}
export function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
if (!target || typeof target !== "object") return null;
const maybeNode = target as {
nodeType?: number;
parentElement?: Element | null;
};
if (maybeNode.nodeType === 1) return target as HTMLElement;
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
return maybeNode.parentElement as HTMLElement;
}
return null;
}
export function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
const el = getEventTargetElement(target);
if (!el) return false;
return Boolean(
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
);
}
export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
const isMac =
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
const modifier = isMac ? "Cmd" : "Ctrl";
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
}
type ElementMatchSelection = Pick<
DomEditSelection,
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
>;
function matchesByDomId(
selection: ElementMatchSelection,
element: TimelineElement,
selectionSourceFile: string,
): boolean {
if (!selection.id) return false;
return (
element.domId === selection.id && (element.sourceFile || "index.html") === selectionSourceFile
);
}
function matchesByCompositionHost(
selection: ElementMatchSelection,
element: TimelineElement,
): boolean {
if (!selection.isCompositionHost || !selection.compositionSrc) return false;
return element.compositionSrc === selection.compositionSrc;
}
function matchesBySelector(selection: ElementMatchSelection, element: TimelineElement): boolean {
if (!selection.selector) return false;
return (
element.selector === selection.selector &&
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
(element.sourceFile ?? "index.html") === selection.sourceFile
);
}
function elementMatchesSelection(
selection: ElementMatchSelection,
element: TimelineElement,
selectionSourceFile: string,
): boolean {
return (
matchesByDomId(selection, element, selectionSourceFile) ||
matchesByCompositionHost(selection, element) ||
matchesBySelector(selection, element)
);
}
export function findMatchingTimelineElementId(
selection: ElementMatchSelection,
elements: TimelineElement[],
): string | null {
const selectionSourceFile = selection.sourceFile || "index.html";
const match = elements.find((el) => elementMatchesSelection(selection, el, selectionSourceFile));
if (match) return match.key ?? match.id;
// Child inside a sub-composition: return a qualified ID so the expansion
// hook can resolve the child via clipParentMap even though no timeline
// element exists for it yet (the expansion creates it on the fly).
if (selection.id && selectionSourceFile !== "index.html") {
return `${selectionSourceFile}#${selection.id}`;
}
return null;
}
/**
* A selected DOM node may be a static descendant of a clip (e.g. the `.num` text
* inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest
* ancestor that IS a clip so the timeline still selects + inline-expands around it.
*/
export function findTimelineIdByAncestor(
element: Element | null | undefined,
elements: TimelineElement[],
sourceFile: string,
): string | null {
let ancestor = element?.parentElement ?? null;
while (ancestor) {
const id = ancestor.id;
if (id) {
const match = elements.find(
(el) => el.domId === id && (el.sourceFile ?? "index.html") === sourceFile,
);
if (match) return match.key ?? match.id;
}
ancestor = ancestor.parentElement;
}
return null;
}
/**
* Resolve the timeline element id for a DOM selection: direct match first, then
* nearest clip ancestor. The ancestor lookup resolves against the selection's own
* source file, falling back to the active composition path, then index.html — so a
* sub-composition selection with no explicit sourceFile resolves against the comp
* currently open, not always the root file.
*/
export function resolveTimelineIdForSelection(
selection: DomEditSelection,
elements: TimelineElement[],
activeCompPath: string | null,
): string | null {
return (
findMatchingTimelineElementId(selection, elements) ??
findTimelineIdByAncestor(
selection.element,
elements,
selection.sourceFile || activeCompPath || "index.html",
)
);
}
export function resolveTimelineSelectionSeekTime(
currentTime: number,
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
): number | null {
if (!element) return null;
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return null;
const start = Math.max(0, element.start);
const end = Math.max(start, start + Math.max(0, element.duration));
const time = Number.isFinite(currentTime) ? currentTime : start;
return clampNumber(time, start, end);
}
export function clampNumber(value: number, min: number, max: number): number {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
// fallow-ignore-next-line unused-export
export { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
export function collectHtmlIds(source: string): string[] {
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
}
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
image: 3,
video: 5,
audio: 5,
};
export async function resolveDroppedAssetDuration(
projectId: string,
assetPath: string,
kind: TimelineAssetKind,
): Promise<number> {
if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
const media = document.createElement(kind === "video" ? "video" : "audio");
media.preload = "metadata";
media.src = `/api/projects/${projectId}/preview/${assetPath}`;
const duration = await new Promise<number>((resolve) => {
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
const finalize = (value: number) => {
window.clearTimeout(timeout);
resolve(value);
};
media.addEventListener(
"loadedmetadata",
() => {
const raw = Number(media.duration);
finalize(
Number.isFinite(raw) && raw > 0
? roundToCenti(raw)
: DEFAULT_TIMELINE_ASSET_DURATION[kind],
);
},
{ once: true },
);
media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
once: true,
});
});
media.src = "";
media.load();
return duration;
}
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
addStudioPendingEditFlushListener,
flushStudioPendingEdits,
trackStudioPendingEdit,
} from "./studioPendingEdits";
describe("studio pending edit flush", () => {
it("waits for mounted panels to persist pending local edits", async () => {
const persist = vi.fn(async () => undefined);
const remove = addStudioPendingEditFlushListener(persist);
try {
await flushStudioPendingEdits();
expect(persist).toHaveBeenCalledTimes(1);
} finally {
remove();
}
});
it("waits for edits already started by unmounted panels", async () => {
const steps: string[] = [];
let resolvePersist!: () => void;
trackStudioPendingEdit(
new Promise<void>((resolve) => {
resolvePersist = resolve;
}).then(() => {
steps.push("persisted");
}),
);
const flushed = flushStudioPendingEdits().then(() => {
steps.push("flushed");
});
await Promise.resolve();
expect(steps).toEqual([]);
resolvePersist();
await flushed;
expect(steps).toEqual(["persisted", "flushed"]);
});
});
@@ -0,0 +1,45 @@
const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
interface StudioFlushPendingEditsDetail {
promises: Array<Promise<unknown>>;
}
const pendingEditPromises = new Set<Promise<unknown>>();
export function trackStudioPendingEdit(
result: Promise<unknown> | unknown,
): Promise<unknown> | undefined {
if (!result) return undefined;
const promise = Promise.resolve(result);
pendingEditPromises.add(promise);
promise.then(
() => pendingEditPromises.delete(promise),
() => pendingEditPromises.delete(promise),
);
return promise;
}
export async function flushStudioPendingEdits(): Promise<void> {
const detail: StudioFlushPendingEditsDetail = { promises: [] };
window.dispatchEvent(
new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
);
while (detail.promises.length > 0 || pendingEditPromises.size > 0) {
const promises = [...detail.promises, ...pendingEditPromises];
detail.promises = [];
await Promise.allSettled(promises);
}
}
export function addStudioPendingEditFlushListener(
handler: () => Promise<unknown> | unknown,
): () => void {
const listener = (event: Event) => {
const detail = (event as CustomEvent<StudioFlushPendingEditsDetail>).detail;
if (!detail?.promises) return;
const promise = trackStudioPendingEdit(handler());
if (promise) detail.promises.push(promise);
};
window.addEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener);
return () => window.removeEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener);
}
@@ -0,0 +1,175 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import {
coversComposition,
getPreviewTargetFromPointer,
pauseStudioPreviewPlayback,
} from "./studioPreviewHelpers";
function domRect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
};
}
function stubRect(el: Element, rect: DOMRect): void {
el.getBoundingClientRect = () => rect;
}
describe("coversComposition (full-bleed canvas-pick exclusion)", () => {
const viewport = { width: 1920, height: 1080 };
it("treats a full-bleed scene wrapper as covering the composition", () => {
expect(coversComposition({ width: 1920, height: 1080 }, viewport)).toBe(true);
expect(coversComposition({ width: 1900, height: 1040 }, viewport)).toBe(true); // ~99%/96%
});
it("does NOT exclude inner content (a stat card, a heading)", () => {
expect(coversComposition({ width: 320, height: 180 }, viewport)).toBe(false);
expect(coversComposition({ width: 1900, height: 200 }, viewport)).toBe(false); // wide but short
expect(coversComposition({ width: 200, height: 1040 }, viewport)).toBe(false); // tall but narrow
});
it("needs BOTH axes near full-bleed (>=95%)", () => {
expect(coversComposition({ width: 1800, height: 1080 }, viewport)).toBe(false); // 93.75% wide
expect(coversComposition({ width: 1920, height: 1000 }, viewport)).toBe(false); // 92.6% tall
});
it("guards against a degenerate viewport", () => {
expect(coversComposition({ width: 100, height: 100 }, { width: 0, height: 0 })).toBe(false);
expect(coversComposition({ width: 100, height: 100 }, { width: 1, height: 1 })).toBe(false);
});
});
describe("pauseStudioPreviewPlayback", () => {
it("pauses through __player without pausing sibling timelines directly", () => {
const playerPause = vi.fn();
const timelinePause = vi.fn();
const siblingPause = vi.fn();
const iframe = {
contentWindow: {
__player: {
getTime: () => 4.25,
pause: playerPause,
},
__timeline: {
time: () => 4.25,
pause: timelinePause,
},
__timelines: {
root: {
pause: siblingPause,
},
},
},
} as unknown as HTMLIFrameElement;
expect(pauseStudioPreviewPlayback(iframe)).toBe(4.25);
expect(playerPause).toHaveBeenCalledTimes(1);
expect(timelinePause).not.toHaveBeenCalled();
expect(siblingPause).not.toHaveBeenCalled();
});
it("falls back to pausing timelines directly when __player is unavailable", () => {
const timelinePause = vi.fn();
const siblingPause = vi.fn();
const iframe = {
contentWindow: {
__timeline: {
time: () => 2.5,
pause: timelinePause,
},
__timelines: {
root: {
pause: siblingPause,
},
},
},
} as unknown as HTMLIFrameElement;
expect(pauseStudioPreviewPlayback(iframe)).toBe(2.5);
expect(timelinePause).toHaveBeenCalledTimes(1);
expect(siblingPause).toHaveBeenCalledTimes(1);
});
});
describe("getPreviewTargetFromPointer", () => {
it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe document");
doc.body.innerHTML = `
<main id="scene" data-composition-id="scene">
<h1 id="headline">Launch title</h1>
<div id="overlay-parent" style="pointer-events: none;">
<div id="overlay" style="position: absolute; background: rgba(0, 0, 0, 0.1);"></div>
</div>
</main>
`;
const scene = doc.getElementById("scene");
const headline = doc.getElementById("headline");
const overlayParent = doc.getElementById("overlay-parent");
const overlay = doc.getElementById("overlay");
if (!scene || !headline || !overlayParent || !overlay) {
throw new Error("Expected preview fixture elements");
}
stubRect(iframe, domRect(0, 0, 400, 300));
stubRect(scene, domRect(0, 0, 400, 300));
stubRect(headline, domRect(40, 40, 160, 48));
stubRect(overlayParent, domRect(0, 0, 360, 260));
stubRect(overlay, domRect(0, 0, 360, 260));
doc.elementsFromPoint = () => [overlay, overlayParent, headline, scene];
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
iframe.remove();
});
it("honors a CSS-class pointer-events:auto opt-in under a pointer-events:none ancestor", () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe document");
doc.head.innerHTML = `<style>.clickable { pointer-events: auto; }</style>`;
doc.body.innerHTML = `
<main id="scene" data-composition-id="scene">
<div id="overlay-parent" style="pointer-events: none;">
<button id="clickable-child" class="clickable">Play</button>
</div>
</main>
`;
const scene = doc.getElementById("scene");
const overlayParent = doc.getElementById("overlay-parent");
const clickableChild = doc.getElementById("clickable-child");
if (!scene || !overlayParent || !clickableChild) {
throw new Error("Expected preview fixture elements");
}
stubRect(iframe, domRect(0, 0, 400, 300));
stubRect(scene, domRect(0, 0, 400, 300));
stubRect(overlayParent, domRect(0, 0, 360, 260));
stubRect(clickableChild, domRect(40, 40, 80, 24));
doc.elementsFromPoint = () => [clickableChild, overlayParent, scene];
expect(getPreviewTargetFromPointer(iframe, 60, 50, "index.html")).toBe(clickableChild);
iframe.remove();
});
});
@@ -0,0 +1,355 @@
import type { DomEditViewport } from "../components/editor/domEditing";
import {
getDomLayerPatchTarget,
isElementComputedVisible,
resolveAllVisualDomEditTargets,
} from "../components/editor/domEditingElement";
import { isHtmlElement } from "../components/editor/domEditingDom";
import { getEventTargetElement } from "./studioHelpers";
interface PreviewLocalPointer {
x: number;
y: number;
viewport: DomEditViewport;
}
// An element is "full-bleed" when its box spans nearly the whole composition on
// BOTH axes. Such elements (scene wrappers, backdrops) are excluded from canvas
// click-picking so a click lands on inner content — or deselects on empty area —
// instead of grabbing the giant container. The Layers panel still selects them.
// ponytail: pure size heuristic; tighten the ratio if decorative full-bleed art
// should remain canvas-selectable.
const FULL_BLEED_RATIO = 0.95;
export function coversComposition(
elRect: { width: number; height: number },
viewport: DomEditViewport,
): boolean {
if (viewport.width <= 1 || viewport.height <= 1) return false;
return (
elRect.width / viewport.width >= FULL_BLEED_RATIO &&
elRect.height / viewport.height >= FULL_BLEED_RATIO
);
}
function isFullBleedTarget(el: HTMLElement, viewport: DomEditViewport): boolean {
return coversComposition(el.getBoundingClientRect(), viewport);
}
function resolvePreviewLocalPointer(
iframe: HTMLIFrameElement,
doc: Document,
win: Window,
clientX: number,
clientY: number,
): PreviewLocalPointer | null {
const iframeRect = iframe.getBoundingClientRect();
const root =
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
const rootRect = root?.getBoundingClientRect();
const rootWidth = rootRect?.width || win.innerWidth;
const rootHeight = rootRect?.height || win.innerHeight;
if (!rootWidth || !rootHeight) return null;
const scaleX = iframeRect.width / rootWidth;
const scaleY = iframeRect.height / rootHeight;
return {
x: (clientX - iframeRect.left) / scaleX,
y: (clientY - iframeRect.top) / scaleY,
viewport: { width: rootWidth, height: rootHeight },
};
}
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
try {
const style = doc.createElement("style");
style.id = POINTER_EVENTS_OVERRIDE_ID;
style.textContent = "* { pointer-events: auto !important; }";
doc.head.appendChild(style);
return style;
} catch {
return null;
}
}
function removePointerEventsOverride(style: HTMLStyleElement | null): void {
try {
style?.remove();
} catch {
// cross-origin or detached doc
}
}
const pointerEventsInheritanceFallbackByDocument = new WeakMap<Document, boolean>();
function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean {
const cached = pointerEventsInheritanceFallbackByDocument.get(doc);
if (cached !== undefined) return cached;
const parent = doc.createElement("div");
const child = doc.createElement("div");
parent.style.pointerEvents = "none";
parent.appendChild(child);
const host = doc.body ?? doc.documentElement;
if (!host) return false;
host.appendChild(parent);
const needsFallback = win.getComputedStyle(child).pointerEvents !== "none";
parent.remove();
pointerEventsInheritanceFallbackByDocument.set(doc, needsFallback);
return needsFallback;
}
// Own declared pointer-events value, via computed style rather than inline
// style, so a CSS-class opt-in/opt-out (not just an inline style attribute)
// is honored when walking back down from a pointer-events:none ancestor.
function hasOwnPointerEventsOverride(el: HTMLElement, win: Window): boolean {
const value = win.getComputedStyle(el).pointerEvents;
return value !== "" && value !== "inherit" && value !== "unset";
}
function inheritsPointerEventsNoneFromAncestor(el: HTMLElement, win: Window): boolean {
let current = el.parentElement;
while (current) {
if (win.getComputedStyle(current).pointerEvents === "none") {
let descendant: HTMLElement | null = el;
while (descendant && descendant !== current) {
if (hasOwnPointerEventsOverride(descendant, win)) {
return win.getComputedStyle(descendant).pointerEvents === "none";
}
descendant = descendant.parentElement;
}
return true;
}
current = current.parentElement;
}
return false;
}
function hasAuthorPointerEventsNone(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return false;
if (win.getComputedStyle(el).pointerEvents === "none") return true;
if (!needsPointerEventsInheritanceFallback(el.ownerDocument, win)) return false;
return inheritsPointerEventsNoneFromAncestor(el, win);
}
function collectPointerEventsNoneTargets(
elements: Iterable<Element | null | undefined>,
): WeakSet<HTMLElement> {
const disabled = new WeakSet<HTMLElement>();
for (const entry of elements) {
if (isHtmlElement(entry) && hasAuthorPointerEventsNone(entry)) {
disabled.add(entry);
}
}
return disabled;
}
// Shared tail of both pointer resolvers: hit-test candidates minus elements the
// author hid from hit-testing via pointer-events:none.
function filterAuthorInteractiveTargets(
elements: Element[],
activeCompositionPath: string | null,
): HTMLElement[] {
const pointerEventsNoneTargets = collectPointerEventsNoneTargets(elements);
return resolveAllVisualDomEditTargets(elements, { activeCompositionPath }).filter(
(el) => !pointerEventsNoneTargets.has(el),
);
}
// Animated group members can move outside their wrapper's static layout box, so
// the empty space inside a group's *visual* bounds (the member-union the overlay
// draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
// point falls within a group's live member-union rect, return that wrapper.
// Innermost (smallest-area) group wins for nested groups.
// fallow-ignore-next-line complexity
function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
let best: HTMLElement | null = null;
let bestArea = Infinity;
for (const group of Array.from(doc.querySelectorAll<HTMLElement>("[data-hf-group]"))) {
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const member of Array.from(group.children)) {
const r = member.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
left = Math.min(left, r.left);
top = Math.min(top, r.top);
right = Math.max(right, r.right);
bottom = Math.max(bottom, r.bottom);
}
if (right < left || x < left || x > right || y < top || y > bottom) continue;
const area = (right - left) * (bottom - top);
if (area < bestArea) {
bestArea = area;
best = group;
}
}
return best;
}
// fallow-ignore-next-line complexity
export function getPreviewTargetFromPointer(
iframe: HTMLIFrameElement,
clientX: number,
clientY: number,
activeCompositionPath: string | null,
): HTMLElement | null {
let doc: Document | null = null;
let win: Window | null = null;
try {
doc = iframe.contentDocument;
win = iframe.contentWindow;
} catch {
return null;
}
if (!doc || !win) return null;
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
if (!localPointer) return null;
let overrideStyle = forcePointerEventsAuto(doc);
try {
if (typeof doc.elementsFromPoint === "function") {
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
removePointerEventsOverride(overrideStyle);
overrideStyle = null;
const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
const visualTarget =
candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
if (visualTarget) return visualTarget;
}
// Belt-and-suspenders: elementsFromPoint is universally supported in the
// browsers this ships in, so the override is already removed by this
// point in practice — but guard the environment without it too, so
// hasAuthorPointerEventsNone below never reads a forced-auto value.
removePointerEventsOverride(overrideStyle);
overrideStyle = null;
// No element hit (e.g. empty space inside an animated group's overlay) — fall
// back to the group whose member-union contains the point, so the whole group
// area is hoverable/selectable, not just where a member currently sits.
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
if (
groupHit &&
!hasAuthorPointerEventsNone(groupHit) &&
getDomLayerPatchTarget(groupHit, activeCompositionPath)
)
return groupHit;
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
if (hasAuthorPointerEventsNone(fallback)) return null;
if (!isElementComputedVisible(fallback)) return null;
if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
return fallback;
} finally {
removePointerEventsOverride(overrideStyle);
}
}
/** Returns all independently-selectable elements at the pointer (topmost first). */
export function getAllPreviewTargetsFromPointer(
iframe: HTMLIFrameElement,
clientX: number,
clientY: number,
activeCompositionPath: string | null,
): HTMLElement[] {
let doc: Document | null = null;
let win: Window | null = null;
try {
doc = iframe.contentDocument;
win = iframe.contentWindow;
} catch {
return [];
}
if (!doc || !win) return [];
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
if (!localPointer) return [];
let overrideStyle = forcePointerEventsAuto(doc);
try {
if (typeof doc.elementsFromPoint === "function") {
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
removePointerEventsOverride(overrideStyle);
overrideStyle = null;
return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
(el) => !isFullBleedTarget(el, localPointer.viewport),
);
}
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
removePointerEventsOverride(overrideStyle);
overrideStyle = null;
if (hasAuthorPointerEventsNone(fallback)) return [];
if (!isElementComputedVisible(fallback)) return [];
if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
return [fallback];
} finally {
removePointerEventsOverride(overrideStyle);
}
}
function objectLike(value: unknown): object | null {
return value && (typeof value === "object" || typeof value === "function") ? value : null;
}
function callPlaybackMethod(target: object | null, key: string): void {
const method = target ? Reflect.get(target, key) : null;
if (typeof method !== "function") return;
try {
method.call(target);
} catch {
// Best-effort playback freeze; drag should still work if playback control is unavailable.
}
}
function readPlaybackTime(target: object | null, key: string): number | null {
const method = target ? Reflect.get(target, key) : null;
if (typeof method !== "function") return null;
try {
const value = method.call(target);
return typeof value === "number" && Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
const win = iframe?.contentWindow;
if (!win) return null;
try {
const player = objectLike(Reflect.get(win, "__player"));
const playerPausedTime = readPlaybackTime(player, "getTime");
const playerPause = player ? Reflect.get(player, "pause") : null;
if (typeof playerPause === "function") {
callPlaybackMethod(player, "pause");
return playerPausedTime;
}
let pausedTime: number | null = null;
const timeline = objectLike(Reflect.get(win, "__timeline"));
pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
callPlaybackMethod(timeline, "pause");
const timelines = objectLike(Reflect.get(win, "__timelines"));
if (timelines) {
for (const value of Object.values(timelines)) {
const timelineRecord = objectLike(value);
pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
callPlaybackMethod(timelineRecord, "pause");
}
}
return pausedTime;
} catch {
return null;
}
}
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import {
StudioSaveHttpError,
StudioSaveNetworkError,
buildStudioSaveFailureProperties,
getStudioSaveStatusCode,
retryStudioSave,
} from "./studioSaveDiagnostics";
describe("studio save diagnostics", () => {
it("builds save_failure properties with stable diagnostics", () => {
const error = new StudioSaveHttpError("Failed to save index.html (503)", 503);
expect(
buildStudioSaveFailureProperties({
source: "code_editor",
error,
filePath: "index.html",
mutationType: "put",
attempt: 3,
}),
).toEqual({
source: "code_editor",
error_message: "Failed to save index.html (503)",
status_code: 503,
file_path: "index.html",
mutation_type: "put",
attempt: 3,
label: undefined,
target_id: undefined,
target_selector: undefined,
target_source_file: undefined,
});
});
it("reads nested status codes from error causes", () => {
const cause = new StudioSaveHttpError("Too many requests", 429);
const error = new Error("retry wrapper") as Error & { cause?: unknown };
error.cause = cause;
expect(getStudioSaveStatusCode(error)).toBe(429);
});
it("retries transient save failures with exponential backoff and jitter", async () => {
const sleeps: number[] = [];
const operation = vi
.fn<(attempt: number) => Promise<string>>()
.mockRejectedValueOnce(new StudioSaveHttpError("Server restarting", 503))
.mockRejectedValueOnce(new StudioSaveHttpError("Still restarting", 503))
.mockRejectedValueOnce(new StudioSaveHttpError("Almost ready", 503))
.mockResolvedValue("saved");
await expect(
retryStudioSave(operation, {
random: () => 0.5,
sleep: async (delayMs) => {
sleeps.push(delayMs);
},
}),
).resolves.toBe("saved");
expect(operation).toHaveBeenCalledTimes(4);
expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([1, 2, 3, 4]);
expect(sleeps).toEqual([500, 1000, 2000]);
});
it("does not retry non-transient client failures", async () => {
const operation = vi
.fn<(attempt: number) => Promise<string>>()
.mockRejectedValue(new StudioSaveHttpError("Too large", 413));
await expect(
retryStudioSave(operation, {
sleep: async () => {},
}),
).rejects.toThrow("Too large");
expect(operation).toHaveBeenCalledTimes(1);
});
it("retries typed network failures", async () => {
const operation = vi
.fn<(attempt: number) => Promise<string>>()
.mockRejectedValueOnce(new StudioSaveNetworkError("network dropped"))
.mockResolvedValue("saved");
await expect(
retryStudioSave(operation, {
sleep: async () => {},
}),
).resolves.toBe("saved");
expect(operation).toHaveBeenCalledTimes(2);
});
it("does not retry plain JavaScript errors", async () => {
const operation = vi
.fn<(attempt: number) => Promise<string>>()
.mockRejectedValue(new Error("local assertion failed"));
await expect(
retryStudioSave(operation, {
sleep: async () => {},
}),
).rejects.toThrow("local assertion failed");
expect(operation).toHaveBeenCalledTimes(1);
});
it("aborts while waiting between retry attempts", async () => {
const controller = new AbortController();
const operation = vi
.fn<(attempt: number) => Promise<string>>()
.mockRejectedValue(new StudioSaveHttpError("Server restarting", 503));
const pending = retryStudioSave(operation, {
signal: controller.signal,
sleep: async (_delayMs, signal) => {
controller.abort();
if (signal?.aborted) throw new DOMException("Save aborted", "AbortError");
},
});
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(operation).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,203 @@
import { trackStudioEvent } from "./studioTelemetry";
type StudioTelemetryValue = string | number | boolean | null | undefined;
const STUDIO_SAVE_ATTEMPT_PROPERTY = "__studioSaveAttempt";
export interface StudioSaveFailureInput {
source: string;
error: unknown;
statusCode?: number | null;
filePath?: string | null;
mutationType?: string | null;
attempt?: number | null;
label?: string | null;
targetId?: string | null;
targetSelector?: string | null;
targetSourceFile?: string | null;
}
export class StudioSaveHttpError extends Error {
readonly statusCode: number;
readonly alreadyToasted: boolean;
constructor(message: string, statusCode: number, options: { alreadyToasted?: boolean } = {}) {
super(message);
this.name = "StudioSaveHttpError";
this.statusCode = statusCode;
this.alreadyToasted = options.alreadyToasted ?? false;
}
}
export class StudioSaveNetworkError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "StudioSaveNetworkError";
}
}
function readNumericProperty(value: object, key: string): number | undefined {
const record = value as Record<string, unknown>;
const property = record[key];
return typeof property === "number" && Number.isFinite(property) ? property : undefined;
}
function createStudioSaveAbortError(): Error {
if (typeof DOMException !== "undefined") return new DOMException("Save aborted", "AbortError");
const error = new Error("Save aborted");
error.name = "AbortError";
return error;
}
function throwIfStudioSaveAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createStudioSaveAbortError();
}
function attachStudioSaveAttempt(error: unknown, attempt: number): unknown {
if (!error || typeof error !== "object") return error;
try {
Object.defineProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY, {
value: attempt,
configurable: true,
});
} catch {
// Best-effort diagnostic only.
}
return error;
}
export function getStudioSaveErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error.trim()) return error;
return "Unknown save failure";
}
export function getStudioSaveStatusCode(error: unknown): number | undefined {
if (!error || typeof error !== "object") return undefined;
const direct =
readNumericProperty(error, "statusCode") ??
readNumericProperty(error, "status") ??
readNumericProperty(error, "status_code");
if (direct != null) return direct;
const cause = (error as { cause?: unknown }).cause;
if (cause && cause !== error) return getStudioSaveStatusCode(cause);
return undefined;
}
function getStudioSaveAttempt(error: unknown): number | undefined {
if (!error || typeof error !== "object") return undefined;
const direct = readNumericProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY);
if (direct != null) return direct;
const cause = (error as { cause?: unknown }).cause;
if (cause && cause !== error) return getStudioSaveAttempt(cause);
return undefined;
}
function isStudioSaveAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
function isRetryableStudioSaveError(error: unknown): boolean {
if (isStudioSaveAbortError(error)) return false;
if (error instanceof StudioSaveNetworkError) return true;
const statusCode = getStudioSaveStatusCode(error);
if (statusCode == null) return false;
return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500;
}
export function buildStudioSaveFailureProperties(
input: StudioSaveFailureInput,
): Record<string, StudioTelemetryValue> {
const statusCode = input.statusCode ?? getStudioSaveStatusCode(input.error) ?? null;
const attempt = input.attempt ?? getStudioSaveAttempt(input.error) ?? undefined;
return {
source: input.source,
error_message: getStudioSaveErrorMessage(input.error),
status_code: statusCode,
file_path: input.filePath ?? input.targetSourceFile ?? undefined,
mutation_type: input.mutationType ?? undefined,
attempt,
label: input.label ?? undefined,
target_id: input.targetId ?? undefined,
target_selector: input.targetSelector ?? undefined,
target_source_file: input.targetSourceFile ?? undefined,
};
}
export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
}
export async function createStudioSaveHttpError(
response: Response,
fallbackMessage: string,
options: { alreadyToasted?: boolean } = {},
): Promise<StudioSaveHttpError> {
let body = "";
try {
body = await response.text();
} catch {
body = "";
}
const detail = body.trim().slice(0, 300);
const message = detail
? `${fallbackMessage} (${response.status}): ${detail}`
: `${fallbackMessage} (${response.status})`;
return new StudioSaveHttpError(message, response.status, options);
}
export async function retryStudioSave<T>(
operation: (attempt: number) => Promise<T>,
options: {
retries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
jitterRatio?: number;
random?: () => number;
signal?: AbortSignal;
shouldRetry?: (error: unknown, attempt: number) => boolean;
sleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
} = {},
): Promise<T> {
const retries = options.retries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 500;
const maxDelayMs = options.maxDelayMs ?? 8000;
const jitterRatio = options.jitterRatio ?? 0.25;
const random = options.random ?? Math.random;
const shouldRetry = options.shouldRetry ?? isRetryableStudioSaveError;
const sleep =
options.sleep ??
((delayMs: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
throwIfStudioSaveAborted(signal);
const onAbort = () => {
globalThis.clearTimeout(timeout);
reject(createStudioSaveAbortError());
};
const timeout = globalThis.setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delayMs);
signal?.addEventListener("abort", onAbort, { once: true });
}));
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
throwIfStudioSaveAborted(options.signal);
return await operation(attempt);
} catch (error) {
const failure = attachStudioSaveAttempt(error, attempt);
if (attempt >= maxAttempts || !shouldRetry(failure, attempt)) throw failure;
const retryIndex = attempt - 1;
const exponentialDelay = Math.min(baseDelayMs * 2 ** retryIndex, maxDelayMs);
const jitterSpan = exponentialDelay * jitterRatio;
const jitteredDelay = Math.round(exponentialDelay + (random() * 2 - 1) * jitterSpan);
const delayMs = Math.max(0, Math.min(maxDelayMs, jitteredDelay));
await sleep(delayMs, options.signal);
}
}
throw new Error("Save retry loop exited unexpectedly");
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildStudioSelectionSnapshot } from "./studioSelectionSnapshot";
import type { DomEditSelection } from "../components/editor/domEditing";
describe("buildStudioSelectionSnapshot", () => {
it("serializes a DOM edit selection without the live HTMLElement", () => {
const selection = {
element: { tagName: "H1" } as HTMLElement,
id: null,
hfId: "hero-title",
selector: ".title",
selectorIndex: 0,
label: "Hero title",
tagName: "h1",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
textContent: "Launch faster",
dataAttributes: { "data-hf-id": "hero-title" },
inlineStyles: { color: "white" },
computedStyles: { "font-size": "48px" },
textFields: [],
capabilities: { canSelect: true, canEditStyles: true },
} as DomEditSelection;
const snapshot = buildStudioSelectionSnapshot({
projectId: "demo",
selection,
currentTime: 1.25,
});
expect(snapshot).toMatchObject({
schemaVersion: 1,
projectId: "demo",
compositionPath: "index.html",
sourceFile: "index.html",
currentTime: 1.25,
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
thumbnailUrl:
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
});
expect(JSON.stringify(snapshot)).not.toContain("HTMLElement");
expect(snapshot).not.toHaveProperty("element");
});
});
@@ -0,0 +1,72 @@
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
import type { DomEditSelection } from "../components/editor/domEditing";
function round3(value: number): number {
return Math.round(value * 1000) / 1000;
}
function thumbnailUrl({
projectId,
selection,
currentTime,
}: {
projectId: string;
selection: DomEditSelection;
currentTime: number;
}): string {
const compPath = encodeURIComponent(
selection.compositionPath || selection.sourceFile || "index.html",
);
const params = new URLSearchParams({
t: String(round3(currentTime)),
format: "png",
});
if (selection.selector) params.set("selector", selection.selector);
if (selection.selectorIndex != null) params.set("selectorIndex", String(selection.selectorIndex));
return `/api/projects/${encodeURIComponent(projectId)}/thumbnail/${compPath}?${params.toString()}`;
}
export function buildStudioSelectionSnapshot({
projectId,
selection,
currentTime,
}: {
projectId: string;
selection: DomEditSelection;
currentTime: number;
}): StudioSelectionSnapshot {
return {
schemaVersion: 1,
projectId,
compositionPath: selection.compositionPath,
sourceFile: selection.sourceFile,
currentTime: round3(currentTime),
target: {
id: selection.id,
hfId: selection.hfId,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
},
label: selection.label,
tagName: selection.tagName,
boundingBox: {
x: round3(selection.boundingBox.x),
y: round3(selection.boundingBox.y),
width: round3(selection.boundingBox.width),
height: round3(selection.boundingBox.height),
},
textContent: selection.textContent,
dataAttributes: { ...selection.dataAttributes },
inlineStyles: { ...selection.inlineStyles },
computedStyles: { ...selection.computedStyles },
textFields: selection.textFields.map((field) => ({
key: field.key,
label: field.label,
value: field.value,
tagName: field.tagName,
source: field.source,
})),
capabilities: { ...selection.capabilities },
thumbnailUrl: thumbnailUrl({ projectId, selection, currentTime }),
};
}
@@ -0,0 +1,123 @@
import { resolveStudioDistinctId } from "../telemetry/distinctId";
// PostHog public ingest key — write-only, safe to ship in the client bundle
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
const POSTHOG_HOST = "https://us.i.posthog.com";
const FLUSH_INTERVAL_MS = 30_000;
const FLUSH_TIMEOUT_MS = 5_000;
interface EventProperties {
[key: string]: string | number | boolean | null | undefined;
}
interface QueuedEvent {
event: string;
properties: EventProperties;
timestamp: string;
}
let queue: QueuedEvent[] = [];
let flushTimer: ReturnType<typeof setInterval> | null = null;
// Delegates to the single source of truth (telemetry/distinctId.ts) so `studio:*`
// events share one id with `studio_*` / render events, and adopt the CLI's
// distinct_id when the CLI launched Studio.
function getDistinctId(): string {
return resolveStudioDistinctId();
}
function isEnabled(): boolean {
try {
return localStorage.getItem("hf-studio-telemetry-opt-out") !== "1";
} catch {
return true;
}
}
function getSessionProperties(): EventProperties {
return {
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
screen_width: window.screen?.width,
screen_height: window.screen?.height,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
user_agent: navigator.userAgent,
url_hash: location.hash.replace(/#project\//, ""),
};
}
declare const __STUDIO_VERSION__: string;
export function trackStudioEvent(event: string, properties: EventProperties = {}): void {
if (!isEnabled()) return;
queue.push({
event: `studio:${event}`,
properties: { ...getSessionProperties(), ...properties },
timestamp: new Date().toISOString(),
});
if (!flushTimer) {
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
}
}
async function flushEvents(): Promise<void> {
if (queue.length === 0) return;
const batch = queue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: getDistinctId(),
timestamp: e.timestamp,
}));
queue = [];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
try {
await fetch(`${POSTHOG_HOST}/batch/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
signal: controller.signal,
});
} catch {
// Telemetry must never break the studio
} finally {
clearTimeout(timeout);
}
}
// Synchronously drains the queue via sendBeacon — safe to call from any
// tab-hide handler regardless of listener registration order. Exported so
// other modules (e.g. sdkResolverShadow.ts) can force delivery of an event
// they just queued without racing this module's own visibilitychange
// listener below.
export function flushViaBeacon(): void {
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
if (queue.length === 0) return;
const batch = queue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: getDistinctId(),
timestamp: e.timestamp,
}));
queue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
try {
navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
} catch {
// best-effort
}
}
if (typeof window !== "undefined") {
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushViaBeacon();
});
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
function createStorage(): Storage {
const entries = new Map<string, string>();
return {
get length() {
return entries.size;
},
clear: () => entries.clear(),
getItem: (key) => entries.get(key) ?? null,
key: (index) => Array.from(entries.keys())[index] ?? null,
removeItem: (key) => entries.delete(key),
setItem: (key, value) => entries.set(key, value),
};
}
describe("studio UI preferences", () => {
it("merges preference patches into one localStorage entry", () => {
const storage = createStorage();
writeStudioUiPreferences({ timelineVisible: false }, storage);
writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
writeStudioUiPreferences({ audioMuted: true }, storage);
writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
expect(readStudioUiPreferences(storage)).toEqual({
timelineVisible: false,
playbackRate: 1.5,
audioMuted: true,
previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
});
});
it("ignores malformed stored values", () => {
const storage = createStorage();
storage.setItem(
"hf-studio-ui-preferences",
JSON.stringify({
leftCollapsed: "yes",
timelineVisible: true,
playbackRate: Number.NaN,
audioMuted: "false",
previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
}),
);
expect(readStudioUiPreferences(storage)).toEqual({
timelineVisible: true,
});
});
});
describe("timelineSnapEnabled preference", () => {
it("round-trips through storage", () => {
const storage = createStorage();
writeStudioUiPreferences({ timelineSnapEnabled: false }, storage);
expect(readStudioUiPreferences(storage).timelineSnapEnabled).toBe(false);
});
it("ignores non-boolean values", () => {
const storage = createStorage();
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ timelineSnapEnabled: "yes" }));
expect(readStudioUiPreferences(storage).timelineSnapEnabled).toBeUndefined();
});
});
describe("timeline zoom pin persistence", () => {
it("round-trips a pinned manual zoom (survives the post-edit reload)", () => {
const storage = createStorage();
writeStudioUiPreferences(
{ timelineZoomMode: "manual", timelineManualZoomPercent: 250 },
storage,
);
const prefs = readStudioUiPreferences(storage);
expect(prefs.timelineZoomMode).toBe("manual");
expect(prefs.timelineManualZoomPercent).toBe(250);
});
it("ignores an invalid zoom mode and a non-finite percent", () => {
const storage = createStorage();
storage.setItem(
"hf-studio-ui-preferences",
JSON.stringify({ timelineZoomMode: "zoomy", timelineManualZoomPercent: "big" }),
);
const prefs = readStudioUiPreferences(storage);
expect(prefs.timelineZoomMode).toBeUndefined();
expect(prefs.timelineManualZoomPercent).toBeUndefined();
});
});
@@ -0,0 +1,142 @@
export interface StoredPreviewZoomState {
zoomPercent: number;
panX: number;
panY: number;
}
export interface StudioUiPreferences {
leftCollapsed?: boolean;
timelineVisible?: boolean;
timelineHeight?: number;
playbackRate?: number;
audioMuted?: boolean;
previewZoom?: StoredPreviewZoomState;
recentBlocks?: string[];
snapEnabled?: boolean;
gridVisible?: boolean;
gridSpacing?: number;
snapToGrid?: boolean;
/** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
timelineSnapEnabled?: boolean;
/** Transport + ruler readout mode: timecode or frame number. */
timeDisplayMode?: "time" | "frame";
/**
* Timeline zoom mode. Persisted so a zoom PINNED on the first edit survives the
* post-edit iframe reload — otherwise the store reset to "fit" and the duration
* change rescaled every clip (the blink-fix's rescale symptom).
*/
timelineZoomMode?: "fit" | "manual";
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
timelineManualZoomPercent?: number;
}
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getBrowserStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
// fallow-ignore-next-line complexity
function readStorage(storage: Storage | null): StudioUiPreferences {
if (!storage) return {};
try {
const raw = storage.getItem(STUDIO_UI_PREFERENCES_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return {};
const preferences: StudioUiPreferences = {};
if (typeof parsed.leftCollapsed === "boolean") {
preferences.leftCollapsed = parsed.leftCollapsed;
}
if (typeof parsed.timelineVisible === "boolean") {
preferences.timelineVisible = parsed.timelineVisible;
}
if (typeof parsed.timelineHeight === "number" && Number.isFinite(parsed.timelineHeight)) {
preferences.timelineHeight = parsed.timelineHeight;
}
if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
preferences.playbackRate = parsed.playbackRate;
}
if (typeof parsed.audioMuted === "boolean") {
preferences.audioMuted = parsed.audioMuted;
}
if (isRecord(parsed.previewZoom)) {
const { zoomPercent, panX, panY } = parsed.previewZoom;
if (
typeof zoomPercent === "number" &&
Number.isFinite(zoomPercent) &&
typeof panX === "number" &&
Number.isFinite(panX) &&
typeof panY === "number" &&
Number.isFinite(panY)
) {
preferences.previewZoom = { zoomPercent, panX, panY };
}
}
if (Array.isArray(parsed.recentBlocks)) {
preferences.recentBlocks = parsed.recentBlocks.filter(
(v: unknown): v is string => typeof v === "string",
);
}
if (typeof parsed.snapEnabled === "boolean") {
preferences.snapEnabled = parsed.snapEnabled;
}
if (typeof parsed.gridVisible === "boolean") {
preferences.gridVisible = parsed.gridVisible;
}
if (typeof parsed.gridSpacing === "number" && Number.isFinite(parsed.gridSpacing)) {
preferences.gridSpacing = parsed.gridSpacing;
}
if (typeof parsed.snapToGrid === "boolean") {
preferences.snapToGrid = parsed.snapToGrid;
}
if (typeof parsed.timelineSnapEnabled === "boolean") {
preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
}
if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
preferences.timeDisplayMode = parsed.timeDisplayMode;
}
if (parsed.timelineZoomMode === "fit" || parsed.timelineZoomMode === "manual") {
preferences.timelineZoomMode = parsed.timelineZoomMode;
}
if (
typeof parsed.timelineManualZoomPercent === "number" &&
Number.isFinite(parsed.timelineManualZoomPercent)
) {
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
}
return preferences;
} catch {
return {};
}
}
export function readStudioUiPreferences(storage: Storage | null = getBrowserStorage()) {
return readStorage(storage);
}
export function writeStudioUiPreferences(
patch: StudioUiPreferences,
storage: Storage | null = getBrowserStorage(),
) {
if (!storage) return;
try {
const next = {
...readStorage(storage),
...patch,
};
storage.setItem(STUDIO_UI_PREFERENCES_KEY, JSON.stringify(next));
} catch {
/* localStorage may be unavailable or full */
}
}
@@ -0,0 +1,288 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildStudioHash,
normalizeStudioCompositionPath,
normalizeStudioUrlPanelTab,
parseStudioUrlStateFromHash,
resolveMasterCompositionPath,
} from "./studioUrlState";
import { useStudioUrlState } from "../hooks/useStudioUrlState";
import { usePlayerStore } from "../player";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
describe("resolveMasterCompositionPath", () => {
it("prefers index.html when present", () => {
expect(resolveMasterCompositionPath(["frames/a.html", "index.html", "b.html"])).toBe(
"index.html",
);
});
it("falls back to the first .html when there is no index.html", () => {
expect(resolveMasterCompositionPath(["notes.md", "card.html", "hero.html"])).toBe("card.html");
});
it("returns null when the project carries no composition", () => {
expect(resolveMasterCompositionPath(["notes.md", "styles.css"])).toBeNull();
expect(resolveMasterCompositionPath([])).toBeNull();
});
});
describe("normalizeStudioUrlPanelTab", () => {
it("accepts slideshow and variables as valid tabs", () => {
expect(normalizeStudioUrlPanelTab("slideshow", { inspectorPanelsEnabled: true })).toBe(
"slideshow",
);
expect(normalizeStudioUrlPanelTab("variables", { inspectorPanelsEnabled: true })).toBe(
"variables",
);
});
});
function resetPlayerStore() {
usePlayerStore.setState({
isPlaying: false,
currentTime: 0,
duration: 0,
timelineReady: false,
elements: [],
selectedElementId: null,
requestedSeekTime: null,
});
}
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
window.history.replaceState(null, "", "/");
resetPlayerStore();
});
function renderStudioUrlStateHarness(
props: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {},
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const baseProps: React.ComponentProps<typeof StudioUrlStateHarness> = {
projectId: "demo",
activeCompPath: null,
currentTime: 0,
duration: 30,
isPlaying: false,
compositionLoading: false,
refreshKey: 0,
previewIframeRef: { current: null },
rightPanelTab: "renders",
rightCollapsed: true,
timelineVisible: true,
activeCompPathHydrated: true,
domEditSelection: null,
buildDomSelectionFromTarget: () => Promise.resolve(null),
applyDomSelection: () => {},
initialState: {
activeCompPath: null,
currentTime: 4.2,
rightPanelTab: null,
rightCollapsed: null,
timelineVisible: null,
selection: null,
},
};
const render = (nextProps: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {}) => {
act(() => {
root.render(
React.createElement(StudioUrlStateHarness, {
...baseProps,
...props,
...nextProps,
}),
);
});
};
render();
return {
rerender: render,
unmount: () =>
act(() => {
root.unmount();
}),
};
}
function StudioUrlStateHarness(props: Parameters<typeof useStudioUrlState>[0]) {
useStudioUrlState(props);
return null;
}
describe("studio url state", () => {
it("parses persisted studio state from project hash", () => {
const state = parseStudioUrlStateFromHash(
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.25&tab=design&rc=0&tv=1&selFile=index.html&selId=hero",
);
expect(state.activeCompPath).toBe("compositions/title.html");
expect(state.currentTime).toBe(4.25);
expect(state.rightPanelTab).toBe("design");
expect(state.rightCollapsed).toBe(false);
expect(state.timelineVisible).toBe(true);
expect(state.selection).toEqual({
sourceFile: "index.html",
id: "hero",
selector: undefined,
selectorIndex: undefined,
});
});
it("builds a project hash with persisted studio state", () => {
expect(
buildStudioHash("demo", {
activeCompPath: "compositions/title.html",
currentTime: 4.2571,
rightPanelTab: "layers",
rightCollapsed: true,
timelineVisible: false,
selection: {
sourceFile: "index.html",
selector: ".card",
selectorIndex: 2,
},
}),
).toBe(
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.257&tab=layers&rc=1&tv=0&selFile=index.html&selSelector=.card&selIndex=2",
);
});
it("falls back cleanly on invalid values", () => {
const state = parseStudioUrlStateFromHash("#project/demo?tab=nope&t=abc&rc=9&tv=7");
expect(state.activeCompPath).toBeNull();
expect(state.currentTime).toBeNull();
expect(state.rightPanelTab).toBeNull();
expect(state.rightCollapsed).toBeNull();
expect(state.timelineVisible).toBeNull();
expect(state.selection).toBeNull();
});
it("normalizes stale composition paths to the master composition", () => {
expect(
normalizeStudioCompositionPath("compositions/missing.html", [
"index.html",
"compositions/title.html",
]),
).toBeNull();
expect(
normalizeStudioCompositionPath("compositions/title.html", [
"index.html",
"compositions/title.html",
]),
).toBe("compositions/title.html");
});
it("normalizes url tabs against feature flags", () => {
expect(normalizeStudioUrlPanelTab("renders")).toBe("renders");
expect(normalizeStudioUrlPanelTab("layers", { inspectorPanelsEnabled: false })).toBe("renders");
});
it("hydrates seek first, preserves the initial url state, then restores selection", async () => {
vi.useFakeTimers();
window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
const requestSeek = vi.fn();
usePlayerStore.setState({ requestSeek });
const selectedElement = document.createElement("div");
selectedElement.id = "hero";
document.body.append(selectedElement);
const previewDoc = document.implementation.createHTMLDocument("preview");
previewDoc.body.append(selectedElement);
const applyDomSelection = vi.fn();
const restoredSelection = {
element: selectedElement,
id: "hero",
selector: "#hero",
selectorIndex: 0,
sourceFile: "index.html",
tagName: "div",
label: "Hero",
textContent: "",
textFields: [],
capabilities: {
canEditText: false,
canEditLayout: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
canAdjustOpacity: true,
canAdjustFill: true,
canAdjustBorderRadius: true,
canAdjustStroke: true,
canAdjustShadow: true,
canAdjustZIndex: true,
},
computedStyle: {
display: "block",
position: "absolute",
},
};
const harness = renderStudioUrlStateHarness({
previewIframeRef: {
current: { contentDocument: previewDoc } as HTMLIFrameElement,
},
rightPanelTab: "design",
rightCollapsed: false,
applyDomSelection,
buildDomSelectionFromTarget: () => Promise.resolve(restoredSelection),
initialState: {
activeCompPath: null,
currentTime: 4.2,
rightPanelTab: "design",
rightCollapsed: false,
timelineVisible: true,
selection: { id: "hero" },
},
});
expect(requestSeek).toHaveBeenCalledWith(4.2);
expect(applyDomSelection).not.toHaveBeenCalled();
expect(window.location.hash).toContain("t=4.2");
expect(window.location.hash).toContain("tab=design");
act(() => {
vi.advanceTimersByTime(250);
});
expect(window.location.hash).toContain("t=4.2");
expect(applyDomSelection).not.toHaveBeenCalled();
// Drive the hook's internal currentTime read. Per #1311 the hook stopped
// taking currentTime as a prop and now subscribes to the player store
// directly (usePlayerStore((s) => s.currentTime)). The harness prop is a
// no-op; the selection-hydration useEffect's time-stability guard
// (`Math.abs(currentTime - stableTimeRef.current) > 0.05`) only passes
// once the store's currentTime catches up to the seek target.
act(() => {
usePlayerStore.setState({ currentTime: 4.2 });
});
harness.rerender({ currentTime: 4.2 });
await act(async () => {
vi.advanceTimersByTime(250);
// Flush microtasks so the async buildDomSelectionFromTarget Promise resolves
await Promise.resolve();
});
expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });
harness.rerender({ currentTime: 4.2, domEditSelection: restoredSelection });
act(() => {
vi.advanceTimersByTime(250);
});
expect(window.location.hash).toContain("t=4.2");
expect(window.location.hash).toContain("selId=hero");
harness.unmount();
});
});
+145
View File
@@ -0,0 +1,145 @@
import type { RightPanelTab } from "./studioHelpers";
import { buildProjectHash, parseProjectHashRoute } from "./projectRouting";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
import { roundTo3 } from "./rounding";
export interface StudioUrlSelectionState {
sourceFile?: string;
id?: string;
selector?: string;
selectorIndex?: number;
}
export interface StudioUrlState {
activeCompPath: string | null;
currentTime: number | null;
rightPanelTab: RightPanelTab | null;
rightCollapsed: boolean | null;
timelineVisible: boolean | null;
selection: StudioUrlSelectionState | null;
}
const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
/**
* The composition a schema-level panel (Variables / Slideshow) targets on the
* master view, where there is no explicit `activeCompPath`. Prefer the
* `index.html` convention, but fall back to the first `.html` in the file tree
* (composition-browser order) so projects whose entry file is `card.html`,
* `hero.html`, etc. don't silently mis-target a non-existent `index.html`.
* Returns null when the project carries no composition file at all.
*/
export function resolveMasterCompositionPath(fileTree: string[]): string | null {
if (fileTree.includes("index.html")) return "index.html";
return fileTree.find((p) => p.endsWith(".html")) ?? null;
}
export function normalizeStudioUrlPanelTab(
tab: RightPanelTab | null,
options: {
inspectorPanelsEnabled?: boolean;
} = {},
): RightPanelTab | null {
if (!tab) return null;
if (!VALID_TABS.includes(tab)) return null;
const inspectorPanelsEnabled = options.inspectorPanelsEnabled ?? STUDIO_INSPECTOR_PANELS_ENABLED;
if (!inspectorPanelsEnabled && tab !== "renders") return "renders";
return tab;
}
export function normalizeStudioCompositionPath(
activeCompPath: string | null,
fileTree: string[],
): string | null {
if (!activeCompPath || activeCompPath === "index.html") return null;
return fileTree.includes(activeCompPath) ? activeCompPath : null;
}
function parseBoolean(value: string | null): boolean | null {
if (value === "1") return true;
if (value === "0") return false;
return null;
}
function parseNumber(value: string | null): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseTab(value: string | null): RightPanelTab | null {
return VALID_TABS.includes(value as RightPanelTab) ? (value as RightPanelTab) : null;
}
function normalizeSelection(params: URLSearchParams): StudioUrlSelectionState | null {
const sourceFile = params.get("selFile") || undefined;
const id = params.get("selId") || undefined;
const selector = params.get("selSelector") || undefined;
const selectorIndex = parseNumber(params.get("selIndex"));
if (!sourceFile && !id && !selector) return null;
return {
sourceFile,
id,
selector,
selectorIndex: selectorIndex != null ? Math.max(0, Math.floor(selectorIndex)) : undefined,
};
}
function defaultStudioUrlState(): StudioUrlState {
return {
activeCompPath: null,
currentTime: null,
rightPanelTab: null,
rightCollapsed: null,
timelineVisible: null,
selection: null,
};
}
export function parseStudioUrlStateFromHash(hash: string): StudioUrlState {
const route = parseProjectHashRoute(hash);
if (!route) return defaultStudioUrlState();
const { params } = route;
return {
activeCompPath: params.get("comp") || null,
currentTime: parseNumber(params.get("t")),
rightPanelTab: normalizeStudioUrlPanelTab(parseTab(params.get("tab"))),
rightCollapsed: parseBoolean(params.get("rc")),
timelineVisible: parseBoolean(params.get("tv")),
selection: normalizeSelection(params),
};
}
export function readStudioUrlStateFromWindow(): StudioUrlState {
if (typeof window === "undefined") return defaultStudioUrlState();
return parseStudioUrlStateFromHash(window.location.hash);
}
// Pre-existing param-assembly complexity — surfaced by this PR's line shifts.
// fallow-ignore-next-line complexity
export function buildStudioHash(projectId: string, state: StudioUrlState): string {
const params = new URLSearchParams();
params.set("v", "1");
if (state.activeCompPath) params.set("comp", state.activeCompPath);
if (state.currentTime != null && Number.isFinite(state.currentTime)) {
params.set("t", String(Math.max(0, roundTo3(state.currentTime))));
}
if (state.rightPanelTab) params.set("tab", state.rightPanelTab);
if (state.rightCollapsed != null) params.set("rc", state.rightCollapsed ? "1" : "0");
if (state.timelineVisible != null) params.set("tv", state.timelineVisible ? "1" : "0");
if (state.selection) {
if (state.selection.sourceFile) params.set("selFile", state.selection.sourceFile);
if (state.selection.id) params.set("selId", state.selection.id);
if (state.selection.selector) params.set("selSelector", state.selection.selector);
if (typeof state.selection.selectorIndex === "number") {
params.set("selIndex", String(Math.max(0, Math.floor(state.selection.selectorIndex))));
}
}
return buildProjectHash(projectId, params);
}
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import {
buildTimelineFileDropPlacements,
buildTimelineAssetInsertHtml,
getTimelineAssetKind,
insertTimelineAssetIntoSource,
resolveTimelineAssetInitialGeometry,
resolveTimelineAssetSrc,
} from "./timelineAssetDrop";
describe("getTimelineAssetKind", () => {
it("detects image, video, and audio assets", () => {
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video");
expect(getTimelineAssetKind("assets/clip.mov")).toBe("video");
expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
});
});
describe("buildTimelineAssetInsertHtml", () => {
it("builds an image clip with explicit timing and track", () => {
const html = buildTimelineAssetInsertHtml({
id: "photo_asset",
assetPath: "assets/photo.png",
kind: "image",
start: 1.25,
duration: 3,
track: 2,
zIndex: 4,
geometry: { left: 0, top: 0, width: 1280, height: 720 },
});
expect(html).toContain('img id="photo_asset"');
expect(html).toContain("left: 0px");
expect(html).toContain("width: 1280px");
expect(html).not.toContain("inset:");
});
it("builds an audio clip without visual layout styles", () => {
const html = buildTimelineAssetInsertHtml({
id: "music_asset",
assetPath: "assets/music.wav",
kind: "audio",
start: 0.5,
duration: 5,
track: 0,
zIndex: 1,
});
expect(html).toContain("<audio");
expect(html).not.toContain("object-fit");
});
});
describe("resolveTimelineAssetInitialGeometry", () => {
it("uses the target composition dimensions for visual media", () => {
expect(
resolveTimelineAssetInitialGeometry(
`<div data-composition-id="main" data-width="330" data-height="228"></div>`,
),
).toEqual({
left: 0,
top: 0,
width: 330,
height: 228,
});
});
});
describe("resolveTimelineAssetSrc", () => {
it("keeps project-root asset paths for index.html", () => {
expect(resolveTimelineAssetSrc("index.html", "assets/photo.png")).toBe("assets/photo.png");
});
it("rewrites asset paths relative to sub-compositions", () => {
expect(resolveTimelineAssetSrc("compositions/scene-a.html", "assets/photo.png")).toBe(
"../assets/photo.png",
);
});
});
describe("buildTimelineFileDropPlacements", () => {
it("returns no placements for an empty drop set", () => {
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
});
it("uses the dropped start and spaces multiple files by duration on the same track", () => {
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
{ start: 1.5, track: 2 },
{ start: 2.7, track: 2 },
{ start: 4.3, track: 2 },
]);
});
it("uses fallback spacing when a duration is unavailable", () => {
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 0, 1.1])).toEqual([
{ start: 1.5, track: 2 },
{ start: 2.7, track: 2 },
{ start: 7.7, track: 2 },
]);
});
it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
expect(
buildTimelineFileDropPlacements(
{ start: 1.5, track: 2 },
[1.2, 1.6, 1.1],
[
{ start: 0, duration: 8, track: 2 },
{ start: 0, duration: 4, track: 5 },
],
),
).toEqual([
{ start: 1.5, track: 6 },
{ start: 2.7, track: 6 },
{ start: 4.3, track: 6 },
]);
});
it("keeps a requested track above occupied rows when that track is clear", () => {
expect(
buildTimelineFileDropPlacements(
{ start: 1.5, track: 8 },
[1.2, 1.6],
[
{ start: 0, duration: 8, track: 2 },
{ start: 0, duration: 4, track: 5 },
],
),
).toEqual([
{ start: 1.5, track: 8 },
{ start: 2.7, track: 8 },
]);
});
it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
expect(
buildTimelineFileDropPlacements(
{ start: 0, track: 0 },
[1.2, 1.6],
[{ start: 0, duration: 8, track: 0 }],
),
).toEqual([
{ start: 0, track: 1 },
{ start: 1.2, track: 1 },
]);
});
});
describe("insertTimelineAssetIntoSource", () => {
it("appends the new asset inside the root composition", () => {
const source = `<!doctype html><html><body><div id="root" data-composition-id="main"></div></body></html>`;
const html = insertTimelineAssetIntoSource(
source,
'<img id="photo_asset" data-start="0" data-duration="3" />',
);
expect(html).toContain('data-composition-id="main">');
expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
});
});
@@ -0,0 +1,138 @@
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
import { roundToCenti } from "./rounding";
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block";
const FALLBACK_TIMELINE_FILE_DROP_DURATION = 5;
export type TimelineAssetKind = "image" | "video" | "audio";
export function getTimelineAssetKind(assetPath: string): TimelineAssetKind | null {
if (IMAGE_EXT.test(assetPath)) return "image";
if (VIDEO_EXT.test(assetPath)) return "video";
if (AUDIO_EXT.test(assetPath)) return "audio";
return null;
}
export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
const baseName = assetPath.split("/").pop() ?? "asset";
const normalized = baseName
.replace(/\.[^.]+$/, "")
.replace(/[^a-zA-Z0-9_-]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
const baseId = normalized || "asset";
const ids = new Set(existingIds);
if (!ids.has(baseId)) return baseId;
let suffix = 2;
while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
return `${baseId}_${suffix}`;
}
export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): string {
const targetDir = targetPath.includes("/")
? targetPath.slice(0, targetPath.lastIndexOf("/"))
: "";
if (!targetDir) return assetPath;
const fromParts = targetDir.split("/").filter(Boolean);
const toParts = assetPath.split("/").filter(Boolean);
while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
fromParts.shift();
toParts.shift();
}
const up = fromParts.map(() => "..");
const relative = [...up, ...toParts].join("/");
return relative || assetPath.split("/").pop() || assetPath;
}
export function buildTimelineFileDropPlacements(
placement: { start: number; track: number },
durations: number[],
occupiedClips: Array<{ start: number; duration: number; track: number }> = [],
): Array<{ start: number; track: number }> {
let nextStart = roundToCenti(Math.max(0, placement.start));
const sequenceStart = nextStart;
const resolvedDurations = durations.map((duration) =>
Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION,
);
const sequenceEnd = resolvedDurations.reduce(
(end, duration) => roundToCenti(end + duration),
sequenceStart,
);
const overlapsDropTrack = occupiedClips.some((clip) => {
if (clip.track !== placement.track) return false;
const clipStart = Math.max(0, clip.start);
const clipEnd = clipStart + Math.max(0, clip.duration);
return sequenceStart < clipEnd && sequenceEnd > clipStart;
});
const track = overlapsDropTrack
? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1
: placement.track;
return resolvedDurations.map((duration) => {
const start = nextStart;
nextStart = roundToCenti(nextStart + duration);
return { start, track };
});
}
export function resolveTimelineAssetInitialGeometry(source: string): {
left: number;
top: number;
width: number;
height: number;
} {
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
return {
left: 0,
top: 0,
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
};
}
export function buildTimelineAssetInsertHtml(input: {
id: string;
assetPath: string;
kind: TimelineAssetKind;
start: number;
duration: number;
track: number;
zIndex: number;
geometry?: { left: number; top: number; width: number; height: number };
}): string {
const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
if (input.kind === "image") {
return `<img ${sharedAttrs} style="${visualStyles}" />`;
}
if (input.kind === "video") {
return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
}
return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;
}
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
if (!match || match.index == null) {
throw new Error("No composition root found in target source");
}
const insertAt = match.index + match[0].length;
const lineStart = source.lastIndexOf("\n", match.index);
const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
const childIndent = leadingWhitespace + " ";
const indented = assetHtml
.split("\n")
.map((line, i) => (i === 0 ? line : childIndent + line))
.join("\n");
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
shouldHandleTimelineToggleHotkey,
} from "./timelineDiscovery";
describe("shouldHandleTimelineToggleHotkey", () => {
it("accepts Shift+T when focus is not inside an editor", () => {
expect(
shouldHandleTimelineToggleHotkey({
key: "T",
shiftKey: true,
metaKey: false,
ctrlKey: false,
altKey: false,
target: {
tagName: "DIV",
isContentEditable: false,
closest: () => null,
},
} as KeyboardEvent),
).toBe(true);
});
it("ignores the shortcut inside text inputs", () => {
expect(
shouldHandleTimelineToggleHotkey({
key: "t",
shiftKey: true,
metaKey: false,
ctrlKey: false,
altKey: false,
target: {
tagName: "TEXTAREA",
isContentEditable: false,
closest: () => null,
},
} as KeyboardEvent),
).toBe(false);
});
it("ignores the shortcut inside contenteditable editors", () => {
expect(
shouldHandleTimelineToggleHotkey({
key: "t",
shiftKey: true,
metaKey: false,
ctrlKey: false,
altKey: false,
target: {
tagName: "DIV",
isContentEditable: true,
closest: () => null,
},
} as KeyboardEvent),
).toBe(false);
});
it("requires Shift without other modifiers", () => {
expect(
shouldHandleTimelineToggleHotkey({
key: "t",
shiftKey: false,
metaKey: false,
ctrlKey: false,
altKey: false,
target: null,
} as KeyboardEvent),
).toBe(false);
expect(
shouldHandleTimelineToggleHotkey({
key: "t",
shiftKey: true,
metaKey: true,
ctrlKey: false,
altKey: false,
target: null,
} as KeyboardEvent),
).toBe(false);
});
});
describe("getTimelineToggleTitle", () => {
it("includes the shortcut in both show and hide titles", () => {
expect(getTimelineToggleTitle(true)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
expect(getTimelineToggleTitle(false)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
});
});
@@ -0,0 +1,41 @@
export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T";
type TimelineToggleHotkeyEvent = Pick<
KeyboardEvent,
"key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target"
>;
interface EditableTargetLike {
tagName?: string;
isContentEditable?: boolean;
closest?: (selector: string) => unknown;
getAttribute?: (name: string) => string | null;
}
export function isEditableTarget(target: EventTarget | null): boolean {
if (!target || typeof target !== "object") return false;
const element = target as EditableTargetLike;
const tagName = element.tagName?.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
if (element.isContentEditable) return true;
const role = element.getAttribute?.("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") return true;
return Boolean(
element.closest?.(
"input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor",
),
);
}
export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEvent): boolean {
if (event.metaKey || event.ctrlKey || event.altKey) return false;
if (!event.shiftKey) return false;
if (event.key.toLowerCase() !== "t") return false;
return !isEditableTarget(event.target);
}
export function getTimelineToggleTitle(timelineVisible: boolean): string {
return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`;
}
@@ -0,0 +1,110 @@
import { describe, it, expect } from "vitest";
import type { TimelineElement } from "../player/store/playerStore";
import {
SPLIT_BOUNDARY_EPSILON_S,
canSplitElementAt,
isSplitTimeWithinBounds,
selectSplittableElements,
} from "./timelineElementSplit";
function element(overrides: Partial<TimelineElement> = {}): TimelineElement {
return {
id: "el-1",
tag: "div",
start: 1,
duration: 4,
track: 0,
...overrides,
};
}
describe("isSplitTimeWithinBounds", () => {
const start = 1;
const duration = 4;
const end = start + duration;
it("accepts the exact lower clamp boundary", () => {
// The timeline canvas clamps an edge click to exactly
// start + SPLIT_BOUNDARY_EPSILON_S, so that value must be splittable.
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S, start, duration)).toBe(true);
});
it("accepts the exact upper clamp boundary", () => {
expect(
isSplitTimeWithinBounds(start + duration - SPLIT_BOUNDARY_EPSILON_S, start, duration),
).toBe(true);
});
it("accepts an interior split time", () => {
expect(isSplitTimeWithinBounds(3, start, duration)).toBe(true);
});
it("rejects times at or outside the clip edges", () => {
expect(isSplitTimeWithinBounds(start, start, duration)).toBe(false);
expect(isSplitTimeWithinBounds(end, start, duration)).toBe(false);
expect(isSplitTimeWithinBounds(start - 1, start, duration)).toBe(false);
expect(isSplitTimeWithinBounds(end + 1, start, duration)).toBe(false);
});
it("rejects times inside the epsilon margins", () => {
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S / 2, start, duration)).toBe(
false,
);
expect(isSplitTimeWithinBounds(end - SPLIT_BOUNDARY_EPSILON_S / 2, start, duration)).toBe(
false,
);
});
it("rejects every time on a clip shorter than two epsilons", () => {
// Math.max(min, Math.min(max, t)) collapses to min when the clip is too
// short for the clamp range; that collapsed value must still be rejected.
const shortDuration = SPLIT_BOUNDARY_EPSILON_S;
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S, start, shortDuration)).toBe(
false,
);
expect(isSplitTimeWithinBounds(start + shortDuration / 2, start, shortDuration)).toBe(false);
});
});
describe("canSplitElementAt", () => {
it("accepts a splittable element at an interior time", () => {
expect(canSplitElementAt(element({ start: 1, duration: 4 }), 3)).toBe(true);
});
it("rejects a time inside the boundary epsilon", () => {
expect(
canSplitElementAt(element({ start: 1, duration: 4 }), 1 + SPLIT_BOUNDARY_EPSILON_S / 2),
).toBe(false);
});
it("rejects locked, implicit and sub-composition elements", () => {
expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false);
expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false);
expect(canSplitElementAt(element({ compositionSrc: "child.html" }), 3)).toBe(false);
});
});
describe("selectSplittableElements", () => {
it("excludes a clip shorter than two epsilons even when the time is inside it", () => {
// Regression: split-all used raw start < t < end, so a clip too short for
// the epsilon margin was still selected and produced a degenerate slice.
const tiny = element({ id: "tiny", start: 1, duration: SPLIT_BOUNDARY_EPSILON_S + 0.01 });
const interiorTime = tiny.start + tiny.duration / 2;
expect(interiorTime).toBeGreaterThan(tiny.start);
expect(interiorTime).toBeLessThan(tiny.start + tiny.duration);
expect(selectSplittableElements([tiny], interiorTime)).toEqual([]);
});
it("keeps only the elements whose epsilon-bounded range contains the time", () => {
const inside = element({ id: "inside", start: 0, duration: 4 });
const outside = element({ id: "outside", start: 5, duration: 4 });
const locked = element({ id: "locked", start: 0, duration: 4, timelineLocked: true });
const result = selectSplittableElements([inside, outside, locked], 2);
expect(result.map((el) => el.id)).toEqual(["inside"]);
});
it("accepts an element at the exact lower clamp boundary", () => {
const el = element({ start: 2, duration: 4 });
expect(selectSplittableElements([el], 2 + SPLIT_BOUNDARY_EPSILON_S)).toEqual([el]);
});
});
@@ -0,0 +1,50 @@
import type { TimelineElement } from "../player/store/playerStore";
export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpers";
/** Minimum distance (seconds) from clip boundaries to allow a split. */
export const SPLIT_BOUNDARY_EPSILON_S = 0.03;
/**
* True when splitTime leaves at least SPLIT_BOUNDARY_EPSILON_S on both sides
* of the cut. Inclusive at the epsilon offsets: the timeline canvas clamps
* edge clicks to exactly start/end ± epsilon, so the clamped value must pass.
*/
export function isSplitTimeWithinBounds(
splitTime: number,
clipStart: number,
clipDuration: number,
): boolean {
return (
splitTime >= clipStart + SPLIT_BOUNDARY_EPSILON_S &&
splitTime <= clipStart + clipDuration - SPLIT_BOUNDARY_EPSILON_S
);
}
export function canSplitElement(el: TimelineElement): boolean {
return (
!el.timelineLocked &&
el.timingSource !== "implicit" &&
!el.compositionSrc &&
!!el.duration &&
Number.isFinite(el.duration)
);
}
/**
* True when `el` can be split AND `splitTime` lies within its boundary epsilon.
* Shared by the single-clip and split-all razor paths so both honor the same
* minimum-distance rule (split-all previously used raw `>`/`<`, letting cuts
* land inside the epsilon margin and produce a degenerate slice).
*/
export function canSplitElementAt(el: TimelineElement, splitTime: number): boolean {
return canSplitElement(el) && isSplitTimeWithinBounds(splitTime, el.start, el.duration);
}
/** Elements that the split-all razor action can cut at `splitTime`. */
export function selectSplittableElements(
elements: TimelineElement[],
splitTime: number,
): TimelineElement[] {
return elements.filter((el) => canSplitElementAt(el, splitTime));
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { isAudioTimelineElement, isMusicTrack, resolveBeatSourceTrack } from "./timelineInspector";
import type { TimelineElement } from "../player";
// Minimal element factory for tests
function el(
overrides: Partial<
Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration">
>,
): Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration"> {
return {
tag: "audio",
src: "assets/track.mp3",
id: "el-1",
domId: "el-1",
timelineRole: undefined,
duration: 10,
...overrides,
};
}
describe("isAudioTimelineElement", () => {
it("is true for audio tag", () => {
expect(isAudioTimelineElement(el({ tag: "audio" }))).toBe(true);
});
it("is true for music/sfx/narration semantic tags", () => {
expect(isAudioTimelineElement(el({ tag: "music" }))).toBe(true);
expect(isAudioTimelineElement(el({ tag: "sfx" }))).toBe(true);
expect(isAudioTimelineElement(el({ tag: "narration" }))).toBe(true);
});
it("is true for img/div with an audio src extension", () => {
expect(isAudioTimelineElement(el({ tag: "div", src: "assets/bg.mp3" }))).toBe(true);
expect(isAudioTimelineElement(el({ tag: "div", src: "assets/fx.wav" }))).toBe(true);
});
it("is false for video and image elements", () => {
expect(isAudioTimelineElement(el({ tag: "video", src: "assets/clip.mp4" }))).toBe(false);
expect(isAudioTimelineElement(el({ tag: "img", src: "assets/logo.svg" }))).toBe(false);
});
it("returns false for null/undefined", () => {
expect(isAudioTimelineElement(null)).toBe(false);
expect(isAudioTimelineElement(undefined)).toBe(false);
});
});
describe("isMusicTrack", () => {
it("is true when timelineRole is 'music'", () => {
expect(isMusicTrack(el({ timelineRole: "music" }))).toBe(true);
});
it("is false for explicit non-music roles", () => {
expect(isMusicTrack(el({ timelineRole: "sfx" }))).toBe(false);
expect(isMusicTrack(el({ timelineRole: "voiceover" }))).toBe(false);
});
it("matches music-like ids when no role is set", () => {
expect(isMusicTrack(el({ domId: "bgm", timelineRole: undefined }))).toBe(true);
expect(isMusicTrack(el({ domId: "background_music", timelineRole: undefined }))).toBe(true);
expect(isMusicTrack(el({ domId: "soundtrack", timelineRole: undefined }))).toBe(true);
});
it("is false for generic ids with no role", () => {
expect(isMusicTrack(el({ domId: "my_audio_file", timelineRole: undefined }))).toBe(false);
expect(isMusicTrack(el({ domId: "drop_1", timelineRole: undefined }))).toBe(false);
});
it("is false for non-audio elements even with a music-like id", () => {
expect(isMusicTrack(el({ tag: "img", src: "assets/logo.svg", domId: "bgm" }))).toBe(false);
});
});
describe("resolveBeatSourceTrack", () => {
it("returns null when there are no elements", () => {
expect(resolveBeatSourceTrack([])).toBeNull();
});
it("returns null when there are no audio elements", () => {
const elements = [el({ tag: "img", src: "assets/logo.png" })];
expect(resolveBeatSourceTrack(elements)).toBeNull();
});
it("returns isFallback=false for an explicitly tagged music track", () => {
const music = el({ timelineRole: "music" });
const result = resolveBeatSourceTrack([music]);
expect(result).not.toBeNull();
expect(result!.isFallback).toBe(false);
expect(result!.element).toBe(music);
});
it("prefers the explicit music track over a longer untagged clip", () => {
const music = el({ timelineRole: "music", duration: 30 });
const other = el({ id: "drop_1", domId: "drop_1", timelineRole: undefined, duration: 120 });
const result = resolveBeatSourceTrack([music, other]);
expect(result!.element).toBe(music);
expect(result!.isFallback).toBe(false);
});
it("falls back to the longest untagged audio clip when no music track exists", () => {
const short = el({ id: "drop_1", domId: "drop_1", duration: 5, timelineRole: undefined });
const long = el({ id: "drop_2", domId: "drop_2", duration: 60, timelineRole: undefined });
const result = resolveBeatSourceTrack([short, long]);
expect(result).not.toBeNull();
expect(result!.isFallback).toBe(true);
expect(result!.element).toBe(long);
});
it("excludes explicitly non-music roles (sfx, voiceover) from the fallback", () => {
const sfx = el({ id: "sfx_1", domId: "sfx_1", timelineRole: "sfx", duration: 30 });
const vo = el({ id: "vo_1", domId: "vo_1", timelineRole: "voiceover", duration: 20 });
expect(resolveBeatSourceTrack([sfx, vo])).toBeNull();
});
it("returns isFallback=true for an untagged clip with a non-music id", () => {
const clip = el({ id: "my_audio_file", domId: "my_audio_file", timelineRole: undefined });
const result = resolveBeatSourceTrack([clip]);
expect(result!.isFallback).toBe(true);
});
});
@@ -0,0 +1,62 @@
import type { TimelineElement } from "../player";
const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narration"]);
const AUDIO_SOURCE_EXT_RE = /\.(aac|flac|m4a|mp3|ogg|opus|wav)(?:[?#].*)?$/i;
const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
export function isAudioTimelineElement(
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
): boolean {
if (!element) return false;
const tag = element.tag.trim().toLowerCase();
if (AUDIO_TIMELINE_TAGS.has(tag)) return true;
return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
}
/** True for the music track: an audio element with data-timeline-role="music",
* or — when no role is set — an id matching the music regex. Voiceover/other
* audio (explicit non-music role) is excluded. */
export function isMusicTrack(
element:
| Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole">
| null
| undefined,
): boolean {
if (!element) return false;
if (!isAudioTimelineElement(element)) return false;
if (element.timelineRole === "music") return true;
if (element.timelineRole && element.timelineRole !== "music") return false;
const id = element.domId ?? element.id ?? "";
return MUSIC_ID_RE.test(id);
}
/**
* Resolve the best audio source for beat analysis. An explicitly tagged or
* named music track wins; when none is present (e.g. an audio file dropped
* from Finder with a generic id), the LONGEST untagged audio clip is used as a
* fallback. Ties on duration resolve to the FIRST such clip encountered (the loop
* keeps the current best on `>` only), i.e. discovery/DOM order wins.
* Returns the element and whether it was found via the fallback path.
*
* The `isMusicTrack` predicate is unchanged so beat-snap and drag-exclusion
* logic remain unaffected by this fallback.
*/
export function resolveBeatSourceTrack(
elements: readonly Pick<
TimelineElement,
"tag" | "src" | "id" | "domId" | "timelineRole" | "duration"
>[],
): { element: (typeof elements)[number]; isFallback: boolean } | null {
const explicit = elements.find(isMusicTrack);
if (explicit) return { element: explicit, isFallback: false };
// Fallback: pick the longest audio clip (skipping explicitly non-music roles
// like "sfx" or "voiceover" to avoid triggering beat analysis on those).
let best: (typeof elements)[number] | null = null;
for (const el of elements) {
if (!isAudioTimelineElement(el)) continue;
if (el.timelineRole && el.timelineRole !== "music") continue;
if (!best || el.duration > best.duration) best = el;
}
return best ? { element: best, isFallback: true } : null;
}
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import { applyPatchByTarget, applyPatch } from "./sourcePatcher";
/**
* Reproduction tests for https://github.com/heygen-com/hyperframes/issues/958
*
* The bug: dragging a clip in the Studio timeline rewrites index.html with
* inline style="z-index: N" on EVERY clip, overriding the author's CSS z-index.
* Additionally, void elements (img, audio) get malformed self-closing tags.
*/
const ISSUE_HTML = `<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
#bg-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
#title { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
font: 700 120px sans-serif; color: #fff; z-index: 20; }
</style>
<div id="root" data-composition-id="main" data-start="0" data-duration="10"
data-width="1920" data-height="1080">
<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>
<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>
</div>`;
const VOID_ELEMENT_HTML = `<div id="root" data-composition-id="main" data-start="0" data-duration="14"
data-width="1920" data-height="1080">
<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>
<img id="gif-img" class="clip" data-start="1" data-duration="13" data-track-index="2" src="assets/earth.gif" alt="rotating earth gif" />
<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>
</div>`;
describe("issue #958 — timeline drag must not inject inline z-index", () => {
it("reproduces the old bug: z-index injection on all clips overrides CSS layering", () => {
// Simulate the OLD behavior: buildTrackZIndexMap + loop over all clips
function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
const maxZIndex = uniqueTracks.length;
return new Map(uniqueTracks.map((track, index) => [track, maxZIndex - index]));
}
const elements = [
{ id: "bg-video", track: 0 },
{ id: "title", track: 1 },
];
const trackZIndices = buildTrackZIndexMap(elements.map((e) => e.track));
// Apply the old z-index injection loop
let broken = ISSUE_HTML;
for (const el of elements) {
const nextZIndex = trackZIndices.get(el.track);
if (nextZIndex == null) continue;
broken = applyPatch(broken, el.id, {
type: "inline-style",
property: "z-index",
value: String(nextZIndex),
});
}
// Verify the bug: bg-video gets z-index: 2, title gets z-index: 1
// This INVERTS the intended layering (CSS had bg-video: 0, title: 20)
expect(broken).toContain('id="bg-video"');
expect(broken).toContain('style="z-index: 2"');
expect(broken).toContain('id="title"');
expect(broken).toContain('style="z-index: 1"');
// The title (z-index: 1) is now BEHIND the video (z-index: 2)
// — the opposite of the author's intent (CSS z-index: 20 vs 0)
});
it("verifies the fix: moving a clip only patches data-start and data-track-index", () => {
// Simulate the NEW behavior: only patch the moved clip's timing attributes
const movedElement = { id: "bg-video" };
const updates = { start: "2.5", track: "0" };
let fixed = applyPatchByTarget(
ISSUE_HTML,
{ id: movedElement.id },
{ type: "attribute", property: "start", value: updates.start },
);
fixed = applyPatchByTarget(
fixed,
{ id: movedElement.id },
{ type: "attribute", property: "track-index", value: updates.track },
);
// The moved clip's timing changed
expect(fixed).toContain('id="bg-video" data-start="2.5" data-track-index="0"');
// No inline z-index was injected on ANY element
expect(fixed).not.toContain('style="z-index');
// The title clip is completely untouched
expect(fixed).toContain(
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
);
// CSS z-index declarations in <style> are preserved
expect(fixed).toContain("z-index: 0;");
expect(fixed).toContain("z-index: 20;");
});
it("verifies the fix: deleting a clip does not inject z-index on remaining clips", () => {
// After the element removal API call returns the content without bg-video,
// the old code would loop over remaining clips and inject z-index.
// The new code just uses the removal result as-is.
const afterRemoval = ISSUE_HTML.replace(/<video id="bg-video"[^>]*><\/video>\n /, "");
// No z-index injection step — the result is used directly
expect(afterRemoval).not.toContain('style="z-index');
expect(afterRemoval).toContain(
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
);
});
});
describe("issue #958 — void element inline style injection", () => {
it("reproduces the old bug: self-closing tags get malformed when style is injected", () => {
// The old code did: tag.replace(/>$/, "") + ` style="z-index: 7"`
// But `tag` from the regex capture never includes `>`, and for self-closing
// elements it ends with `/`. The replace was a no-op, producing:
// <img ... / style="z-index: 7">
const img = `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" />`;
const result = applyPatch(img, "gif-img", {
type: "inline-style",
property: "z-index",
value: "7",
});
// With the fix, the style is inserted before the self-closing slash
expect(result).not.toContain("/ style");
expect(result).toContain('style="z-index: 7" />');
});
it("verifies inline style on void elements in a full composition", () => {
// Even though we no longer inject z-index during move/delete,
// the patchInlineStyleInTag fix is still important for other inline style
// patches (e.g., position, opacity) that the Studio applies to void elements.
const result = applyPatch(VOID_ELEMENT_HTML, "gif-img", {
type: "inline-style",
property: "opacity",
value: "0.8",
});
expect(result).toContain('style="opacity: 0.8" />');
expect(result).not.toContain("/ style");
// Other elements untouched
expect(result).toContain(
'<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>',
);
expect(result).toContain(
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
);
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { fitEasesFromVelocity, type FittedKeyframe } from "./velocityEaseFitter";
function makeSamples(
count: number,
duration: number,
velocityFn: (t: number) => number,
): { time: number; properties: Record<string, number> }[] {
const samples = [];
let pos = 0;
for (let i = 0; i <= count; i++) {
const t = (i / count) * duration;
const v = velocityFn(t / duration);
pos += v * (duration / count);
samples.push({ time: t, properties: { x: pos } });
}
return samples;
}
describe("fitEasesFromVelocity", () => {
it("constant speed → no ease assigned", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
const samples = makeSamples(60, 1, () => 100);
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBeUndefined();
});
it("decelerate at end → AE Easy Ease In (slow-end curve)", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
// Start fast, end slow → playback must also be slow at the end (CP2 y=1).
const samples = makeSamples(60, 1, (t) => Math.max(0, 200 * (1 - t)));
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBe("custom(M0,0 C0.333,0.333 0.667,1 1,1)");
});
it("accelerate from start → AE Easy Ease Out (slow-start curve)", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
// Start slow, end fast → playback must also be slow at the start (CP1 y=0).
const samples = makeSamples(60, 1, (t) => 200 * t);
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBe("custom(M0,0 C0.333,0 0.667,0.667 1,1)");
});
it("single keyframe → returns unchanged", () => {
const kfs: FittedKeyframe[] = [{ percentage: 0, properties: { x: 0 } }];
const result = fitEasesFromVelocity(kfs, [], 1);
expect(result).toEqual(kfs);
});
});
@@ -0,0 +1,121 @@
interface TimedSample {
time: number;
value: number;
}
// After Effects convention (ease named by the keyframe side it acts on):
// Easy Ease — slow at both ends (cubic-bezier 0.333,0 0.667,1)
// Easy Ease In — eases *into* the keyframe → decelerates → slow at the END
// Easy Ease Out — eases *out of* the keyframe → accelerates → slow at the START
// The control-point y values must match that polarity (a flat tangent at the
// slow side): slow-end pins CP2 at y=1, slow-start pins CP1 at y=0.
const AE_EASE = "custom(M0,0 C0.333,0 0.667,1 1,1)";
const AE_EASE_IN = "custom(M0,0 C0.333,0.333 0.667,1 1,1)";
const AE_EASE_OUT = "custom(M0,0 C0.333,0 0.667,0.667 1,1)";
const VELOCITY_THRESHOLD = 0.3;
function averageSpeed(samples: TimedSample[], from: number, to: number): number {
const seg = samples.filter((s) => s.time >= from && s.time <= to);
if (seg.length < 2) return 0;
let total = 0;
for (let i = 1; i < seg.length; i++) {
const dt = seg[i].time - seg[i - 1].time;
if (dt > 0) total += Math.abs(seg[i].value - seg[i - 1].value) / dt;
}
return total / (seg.length - 1);
}
function speedAtEdge(
samples: TimedSample[],
t: number,
window: number,
side: "start" | "end",
): number {
const near = samples.filter((s) =>
side === "start" ? s.time >= t && s.time <= t + window : s.time >= t - window && s.time <= t,
);
if (near.length < 2) return 0;
let total = 0;
for (let i = 1; i < near.length; i++) {
const dt = near[i].time - near[i - 1].time;
if (dt > 0) total += Math.abs(near[i].value - near[i - 1].value) / dt;
}
return total / (near.length - 1);
}
export interface FittedKeyframe {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
/**
* Analyze velocity profile of raw samples between keyframes and assign
* per-keyframe eases based on deceleration/acceleration patterns.
*
* For each segment between consecutive keyframes:
* - Constant speed → linear ("none")
* - Decelerates at end → Easy Ease In
* - Accelerates from start → Easy Ease Out
* - Both → Easy Ease (full)
*/
// fallow-ignore-next-line complexity
export function fitEasesFromVelocity(
keyframes: FittedKeyframe[],
rawSamples: { time: number; properties: Record<string, number> }[],
totalDuration: number,
): FittedKeyframe[] {
if (keyframes.length < 2 || rawSamples.length < 3) return keyframes;
const result = [...keyframes.map((kf) => ({ ...kf }))];
for (let i = 1; i < result.length; i++) {
const prevPct = result[i - 1].percentage;
const currPct = result[i].percentage;
const segStart = (prevPct / 100) * totalDuration;
const segEnd = (currPct / 100) * totalDuration;
const segDur = segEnd - segStart;
if (segDur <= 0) continue;
// Use the dominant property (largest range) for velocity analysis
const props = Object.keys(result[i].properties);
let bestProp = props[0] ?? "x";
let bestRange = 0;
for (const p of props) {
const startVal = Number(result[i - 1].properties[p] ?? 0);
const endVal = Number(result[i].properties[p] ?? 0);
const range = Math.abs(endVal - startVal);
if (range > bestRange) {
bestRange = range;
bestProp = p;
}
}
const propSamples: TimedSample[] = rawSamples
.filter((s) => s.time >= segStart && s.time <= segEnd)
.map((s) => ({ time: s.time, value: s.properties[bestProp] ?? 0 }));
if (propSamples.length < 3) continue;
const edgeWindow = segDur * 0.25;
const avgSpd = averageSpeed(propSamples, segStart, segEnd);
if (avgSpd < 1e-6) continue;
const startSpd = speedAtEdge(propSamples, segStart, edgeWindow, "start");
const endSpd = speedAtEdge(propSamples, segEnd, edgeWindow, "end");
const slowStart = startSpd / avgSpd < VELOCITY_THRESHOLD;
const slowEnd = endSpd / avgSpd < VELOCITY_THRESHOLD;
if (slowStart && slowEnd) {
result[i].ease = AE_EASE;
} else if (slowEnd) {
result[i].ease = AE_EASE_IN;
} else if (slowStart) {
result[i].ease = AE_EASE_OUT;
}
// Otherwise leave ease undefined → linear (constant speed)
}
return result;
}