Revert "feat: Persist Studio manual edits via manifest (#593)"

This reverts commit d0abe90a82.
This commit is contained in:
Miguel Ángel
2026-05-04 09:41:36 -07:00
parent 8d83d4f132
commit 26b8e2a985
82 changed files with 711 additions and 15345 deletions
@@ -1,19 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
describe("buildCompositionThumbnailUrl", () => {
it("includes selector and occurrence index for precise element thumbnails", () => {
expect(
buildCompositionThumbnailUrl({
previewUrl: "/api/projects/demo/preview",
seekTime: 1,
duration: 2,
selector: ".card",
selectorIndex: 2,
origin: "http://localhost:3000",
}),
).toBe(
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
);
});
});
@@ -7,7 +7,6 @@ interface CompositionThumbnailProps {
labelColor: string;
accentColor?: string;
selector?: string;
selectorIndex?: number;
seekTime?: number;
duration?: number;
width?: number;
@@ -15,38 +14,7 @@ interface CompositionThumbnailProps {
}
const CLIP_HEIGHT = 66;
const THUMBNAIL_URL_VERSION = "v3";
export function buildCompositionThumbnailUrl({
previewUrl,
seekTime = 2,
duration = 5,
selector,
selectorIndex,
origin,
}: {
previewUrl: string;
seekTime?: number;
duration?: number;
selector?: string;
selectorIndex?: number;
origin: string;
}): string {
const thumbnailBase = previewUrl
.replace("/preview/comp/", "/thumbnail/")
.replace(/\/preview$/, "/thumbnail/index.html");
const midTime = seekTime + duration / 2;
const thumbnailUrl = new URL(thumbnailBase, origin);
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
if (selector) {
thumbnailUrl.searchParams.set("selector", selector);
if (selectorIndex != null && selectorIndex > 0) {
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
}
}
return thumbnailUrl.toString();
}
const THUMBNAIL_URL_VERSION = "v2";
export const CompositionThumbnail = memo(function CompositionThumbnail({
previewUrl,
@@ -54,7 +22,6 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
labelColor,
accentColor = "#6B7280",
selector,
selectorIndex,
seekTime = 2,
duration = 5,
}: CompositionThumbnailProps) {
@@ -81,14 +48,15 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
roRef.current?.disconnect();
});
const url = buildCompositionThumbnailUrl({
previewUrl,
seekTime,
duration,
selector,
selectorIndex,
origin: window.location.origin,
});
const thumbnailBase = previewUrl
.replace("/preview/comp/", "/thumbnail/")
.replace(/\/preview$/, "/thumbnail/index.html");
const midTime = seekTime + duration / 2;
const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
if (selector) thumbnailUrl.searchParams.set("selector", selector);
const url = thumbnailUrl.toString();
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
@@ -98,7 +66,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
src={url}
alt=""
draggable={false}
loading="eager"
loading="lazy"
onLoad={(e) => {
const img = e.currentTarget;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -3,7 +3,6 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { usePlayerStore } from "../store/playerStore";
import { formatTime } from "../lib/time";
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
import { copyTextToClipboard } from "../../utils/clipboard";
interface EditPopoverProps {
rangeStart: number;
@@ -63,8 +62,16 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
}, [start, end, elementsInRange, prompt]);
const handleCopy = useCallback(async () => {
const copied = await copyTextToClipboard(buildClipboardText());
if (!copied) return;
try {
await navigator.clipboard.writeText(buildClipboardText());
} catch {
const ta = document.createElement("textarea");
ta.value = buildClipboardText();
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopiedAgentPrompt(true);
setTimeout(() => {
setCopiedAgentPrompt(false);
@@ -75,8 +82,16 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
const handleCopyPrompt = useCallback(async () => {
const promptText = buildPromptCopyText(prompt);
if (!promptText) return;
const copied = await copyTextToClipboard(promptText);
if (!copied) return;
try {
await navigator.clipboard.writeText(promptText);
} catch {
const ta = document.createElement("textarea");
ta.value = promptText;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopiedPromptOnly(true);
setTimeout(() => {
setCopiedPromptOnly(false);
@@ -1,30 +1,21 @@
import { forwardRef, useRef, useState } from "react";
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
import { useMountEffect } from "../../hooks/useMountEffect";
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
// at module load, which throws under SSR. Defer the import to the mount effect
// so it only runs in the browser.
interface PlayerProps {
projectId?: string;
directUrl?: string;
onLoad: () => void;
portrait?: boolean;
style?: React.CSSProperties;
}
interface HyperframesPlayerElement extends HTMLElement {
iframeElement: HTMLIFrameElement;
}
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
const root = player.shadowRoot;
if (!root) return;
const container = root.querySelector<HTMLElement>(".hfp-container");
const iframe = root.querySelector<HTMLIFrameElement>(".hfp-iframe");
container?.style.setProperty("pointer-events", "auto");
iframe?.style.setProperty("pointer-events", "auto");
}
// Assets are considered ready when every `<video>`/`<audio>` has enough data
// to play through without buffering, and every registered Lottie animation has
// finished loading.
@@ -58,9 +49,18 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
}
}
/**
* Renders a composition preview using the <hyperframes-player> web component.
*
* The web component handles iframe scaling, dimension detection, and
* ResizeObserver internally. This wrapper bridges its inner iframe to the
* forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
* timeline probing, and DOM inspection.
*/
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
({ projectId, directUrl, onLoad, portrait, style }, ref) => {
({ projectId, directUrl, onLoad, portrait }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const loadCountRef = useRef(0);
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [assetsLoading, setAssetsLoading] = useState(false);
@@ -71,9 +71,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
let canceled = false;
let cleanup: (() => void) | undefined;
// Dynamic import registers the custom element in the browser only.
import("@hyperframes/player").then(() => {
if (canceled) return;
// Create the web component imperatively to avoid JSX custom-element typing.
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
const src = directUrl || `/api/projects/${projectId}/preview`;
player.setAttribute("src", src);
@@ -83,8 +85,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
player.style.height = "100%";
player.style.display = "block";
container.appendChild(player);
enableInteractiveIframe(player);
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
const iframe = player.iframeElement;
if (typeof ref === "function") {
ref(iframe);
@@ -92,12 +94,35 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
}
// Prevent the web component's built-in click-to-toggle behavior.
// The studio manages playback exclusively via useTimelinePlayer.
const preventToggle = (e: Event) => e.stopImmediatePropagation();
player.addEventListener("click", preventToggle, { capture: true });
// Forward the iframe's native load event to the studio's onIframeLoad.
const handleLoad = () => {
loadCountRef.current++;
// Reveal animation on reload (hot-reload, composition switch)
if (loadCountRef.current > 1) {
container.classList.remove("preview-revealing");
void container.offsetWidth;
container.classList.add("preview-revealing");
const onEnd = () => container.classList.remove("preview-revealing");
container.addEventListener("animationend", onEnd, { once: true });
}
onLoad();
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
// asset is ready. Without this users can click play before audio has
// buffered — the runtime is resilient (queued play() resolves once
// data arrives), but the overlay communicates why the first frame
// or first audio beat may lag.
//
// Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
// trips we hide the overlay so the UI doesn't appear stuck forever,
// but we log a debug warning so the case is diagnosable — a long
// cold video or a broken asset can legitimately exceed 10 s on a
// slow network.
if (assetPollRef.current) clearInterval(assetPollRef.current);
let lastUnloaded = hasUnloadedAssets(iframe, false);
if (lastUnloaded) {
@@ -110,6 +135,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
if (assetPollRef.current) clearInterval(assetPollRef.current);
assetPollRef.current = null;
setAssetsLoading(false);
if (lastUnloaded) {
console.debug(
"[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
);
}
}
}, 100);
} else {
@@ -124,6 +154,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
if (assetPollRef.current) clearInterval(assetPollRef.current);
assetPollRef.current = null;
container.removeChild(player);
// Clear the forwarded ref
if (typeof ref === "function") {
ref(null);
} else if (ref) {
@@ -139,10 +170,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
});
return (
<div
className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
style={style}
>
<div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
<div ref={containerRef} className="w-full h-full" />
{assetsLoading && (
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 pointer-events-none">
@@ -8,7 +8,6 @@ import {
getTimelinePlayheadLeft,
getTimelineScrollLeftForZoomAnchor,
getTimelineScrollLeftForZoomTransition,
shouldShowTimelineShortcutHint,
shouldHandleTimelineDeleteKey,
shouldAutoScrollTimeline,
} from "./Timeline";
@@ -238,17 +237,6 @@ describe("getTimelineCanvasHeight", () => {
});
});
describe("shouldShowTimelineShortcutHint", () => {
it("shows the hint when the timeline does not vertically overflow", () => {
expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
});
it("hides the hint when timeline tracks need vertical scrolling", () => {
expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
});
});
describe("shouldHandleTimelineDeleteKey", () => {
it("handles Delete and Backspace when focus is not in an editor", () => {
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
@@ -35,7 +35,7 @@ const TRACK_H = 72;
const RULER_H = 24;
const CLIP_Y = 3; // vertical inset inside track
const CLIP_HANDLE_W = 18;
const TIMELINE_SCROLL_BUFFER = 20;
const TIMELINE_SCROLL_BUFFER = 24;
interface TrackVisualStyle extends TimelineTrackStyle {
icon: ReactNode;
@@ -216,14 +216,6 @@ export function getTimelineCanvasHeight(trackCount: number): number {
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
}
export function shouldShowTimelineShortcutHint(
scrollHeight: number,
clientHeight: number,
): boolean {
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
return scrollHeight - clientHeight <= 1;
}
export function shouldHandleTimelineDeleteKey(input: {
key: string;
metaKey?: boolean;
@@ -287,6 +279,7 @@ export function resolveTimelineAssetDrop(
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
};
}
/* ── Component ──────────────────────────────────────────────────── */
interface TimelineProps {
/** Called when user seeks via ruler/track click or playhead drag */
@@ -434,51 +427,30 @@ export const Timeline = memo(function Timeline({
onDeleteElementRef.current = onDeleteElement;
const suppressClickRef = useRef(false);
const [showPopover, setShowPopover] = useState(false);
const [showShortcutHint, setShowShortcutHint] = useState(true);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const shortcutHintRafRef = useRef(0);
const syncShortcutHintVisibility = useCallback(() => {
const scroll = scrollRef.current;
setShowShortcutHint(
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
);
}, []);
const scheduleShortcutHintVisibilitySync = useCallback(() => {
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
shortcutHintRafRef.current = requestAnimationFrame(() => {
shortcutHintRafRef.current = 0;
syncShortcutHintVisibility();
});
}, [syncShortcutHintVisibility]);
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
// useMountEffect can't work here because the component returns null on first
// render (timelineReady=false), so containerRef.current is null when the
// effect fires and the ResizeObserver is never created.
const setContainerRef = useCallback(
(el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
containerRef.current = el;
if (!el) return;
setViewportWidth(el.clientWidth);
scheduleShortcutHintVisibilitySync();
roRef.current = new ResizeObserver(([entry]) => {
setViewportWidth(entry.contentRect.width);
scheduleShortcutHintVisibilitySync();
});
roRef.current.observe(el);
},
[scheduleShortcutHintVisibilitySync],
);
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
containerRef.current = el;
if (!el) return;
setViewportWidth(el.clientWidth);
roRef.current = new ResizeObserver(([entry]) => {
setViewportWidth(entry.contentRect.width);
});
roRef.current.observe(el);
}, []);
// Clean up ResizeObserver on unmount
useMountEffect(() => () => {
roRef.current?.disconnect();
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
});
// Effective duration: max of store duration and the furthest element end.
@@ -523,7 +495,6 @@ export const Timeline = memo(function Timeline({
}
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
}, [draggedClip, trackOrder]);
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
const selectedElement = useMemo(
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
[elements, selectedElementId],
@@ -573,6 +544,7 @@ export const Timeline = memo(function Timeline({
);
previousZoomModeRef.current = zoomMode;
}, [zoomMode]);
useMountEffect(() => {
const unsub = liveTime.subscribe((t) => {
const dur = durationRef.current;
@@ -1040,10 +1012,6 @@ export const Timeline = memo(function Timeline({
);
const majorTickInterval =
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
useEffect(() => {
syncShortcutHintVisibility();
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
if (
@@ -1271,6 +1239,7 @@ export const Timeline = memo(function Timeline({
);
}
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
@@ -1344,7 +1313,7 @@ export const Timeline = memo(function Timeline({
<div
ref={setContainerRef}
aria-label="Timeline"
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
style={{
touchAction: "pan-x pan-y",
background: theme.shellBackground,
@@ -1683,8 +1652,8 @@ export const Timeline = memo(function Timeline({
</div>
</div>
{/* Keyboard shortcut hint */}
{showShortcutHint && !showPopover && !rangeSelection && (
{/* Keyboard shortcut hint — always visible */}
{!showPopover && !rangeSelection && (
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
@@ -63,25 +63,6 @@ export const TimelineClip = memo(function TimelineClip({
const capabilities = getTimelineEditCapabilities(el);
const displayLabel = el.label || el.id || el.tag;
const showHandles = handleOpacity > 0.01;
const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
const glossBackgroundImage = isSelected
? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
: "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
isSelected ? "22" : "1e"
}, transparent 28%)`;
const compositionStripeBackgroundImage =
isComposition && !hasCustomContent
? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
: undefined;
const clipBackgroundImage = [
compositionStripeBackgroundImage,
glossBackgroundImage,
accentBackgroundImage,
baseBackgroundImage,
]
.filter(Boolean)
.join(", ");
return (
<div
@@ -95,7 +76,13 @@ export const TimelineClip = memo(function TimelineClip({
top: clipY,
bottom: clipY,
borderRadius: theme.clipRadius,
backgroundImage: clipBackgroundImage,
background: isSelected
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
backgroundImage:
isComposition && !hasCustomContent
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
: undefined,
border: `1px solid ${borderColor}`,
boxShadow,
transition:
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
});
});
it("allows moving generic motion clips while keeping trims blocked", () => {
it("disables move and trims for generic motion clips even when patchable", () => {
expect(
getTimelineEditCapabilities({
tag: "section",
@@ -256,7 +256,7 @@ describe("getTimelineEditCapabilities", () => {
selector: ".feature-card",
}),
).toEqual({
canMove: true,
canMove: false,
canTrimStart: false,
canTrimEnd: false,
});
@@ -428,6 +428,7 @@ describe("buildClipRangeSelection", () => {
});
});
});
describe("resolveTimelineAutoScroll", () => {
it("does not scroll when the pointer stays away from the edges", () => {
expect(
@@ -511,6 +512,7 @@ describe("buildTimelineElementAgentPrompt", () => {
).toContain("If this clip is animated with GSAP");
});
});
describe("resolveTimelineResize", () => {
it("shrinks clip duration from the right edge", () => {
expect(
@@ -233,7 +233,7 @@ export function getTimelineEditCapabilities(input: {
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
return {
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
canMove: canPatch && hasDeterministicWindow,
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
};
@@ -273,6 +273,7 @@ export function buildClipRangeSelection(
anchorY: anchor.anchorY,
};
}
export function buildTimelineAgentPrompt({
rangeStart,
rangeEnd,
@@ -346,6 +347,7 @@ export function buildTimelineElementAgentPrompt(element: {
return lines.join("\n");
}
export function formatTimelineAttributeNumber(value: number): string {
return Number(roundToCentiseconds(value).toFixed(2)).toString();
}
@@ -13,30 +13,6 @@ import {
shouldIgnorePlaybackShortcutTarget,
} from "./useTimelinePlayer";
function createDocument(markup: string): Document {
const window = new Window();
Object.assign(window, { SyntaxError });
window.document.body.innerHTML = markup;
return window.document;
}
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
return {
id: null,
label: "",
start: 0,
duration: 4,
track: 0,
kind: "element",
tagName: "div",
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
...overrides,
};
}
function mockTargetMatching(selectorNeedle: string): EventTarget {
return {
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
@@ -57,6 +33,29 @@ function mockKeyboardEvent(
};
}
function createDocument(markup: string): Document {
const window = new Window();
window.document.body.innerHTML = markup;
return window.document;
}
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
return {
id: null,
label: "Element",
start: 0,
duration: 4,
track: 0,
kind: "element",
tagName: "div",
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
...overrides,
};
}
describe("buildStandaloneRootTimelineElement", () => {
it("includes selector and source metadata for standalone composition fallback clips", () => {
expect(
@@ -22,7 +22,7 @@ interface TimelineLike {
isActive: () => boolean;
}
export interface ClipManifestClip {
interface ClipManifestClip {
id: string | null;
label: string;
start: number;
@@ -335,6 +335,7 @@ function buildTimelineElementKey(params: {
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
return `${scope}:${params.id}:${params.fallbackIndex}`;
}
function buildTimelineElementIdentity(params: {
preferredId?: string | null;
label: string;
@@ -556,6 +557,7 @@ export function buildStandaloneRootTimelineElement(params: {
sourceFile: compositionSrc,
};
}
function normalizePreviewViewport(doc: Document, win: Window): void {
if (doc.documentElement) {
doc.documentElement.style.overflow = "hidden";
@@ -1349,9 +1351,6 @@ export function useTimelinePlayer() {
setIsPlaying(false);
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
const togglePlayRef = useRef(togglePlay);
togglePlayRef.current = togglePlay;
const refreshPlayer = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
@@ -1460,6 +1459,8 @@ export function useTimelinePlayer() {
stopRAFLoop();
stopReverseLoop();
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
// Don't reset() on cleanup — preserve timeline elements across iframe refreshes
// to prevent blink. New data will replace old when the iframe reloads.
};
});