mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
Revert "feat: Persist Studio manual edits via manifest (#593)"
This reverts commit d0abe90a82.
This commit is contained in:
@@ -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.
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user