fix(studio): resolve recent timeline regressions

This commit is contained in:
Miguel Ángel
2026-07-01 03:18:45 +00:00
parent 602590b44d
commit 9b311588be
11 changed files with 339 additions and 13 deletions
+20
View File
@@ -1656,6 +1656,26 @@ describe("keyframe mutations", () => {
expect(kfs[1].properties.x).toBe(999);
});
it("addKeyframeToScript — preserves exactly one ease when updating an eased keyframe", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { scale: 1 }, "60%": { scale: 1.18, ease: "power2.out" }, "100%": { scale: 1 } },
duration: 2
}, 0);
`;
const id = getAnimId(script);
const updated = addKeyframeToScript(script, id, 60, { scale: 1.18, x: 10, y: 20 });
const reparsed = parseGsapScript(updated);
const keyframe = reparsed.animations[0].keyframes!.keyframes.find(
(kf) => kf.percentage === 60,
)!;
expect((updated.match(/ease:\s*"power2\.out"/g) ?? []).length).toBe(1);
expect(keyframe.ease).toBe("power2.out");
expect(keyframe.properties).toMatchObject({ scale: 1.18, x: 10, y: 20 });
});
// ── backfillDefaults: editing one keyframe must not move the others ──────
// UX invariant (CapCut/AE): keyframes are independent. Introducing a property
// to one keyframe (e.g. `y` on an x-only tween) must backfill the other
+5 -2
View File
@@ -1994,8 +1994,11 @@ function buildKeyframeValueNode(
properties: Record<string, number | string>,
ease?: string,
): AstNode {
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
const effectiveEase = ease ?? (typeof properties.ease === "string" ? properties.ease : undefined);
const entries = Object.entries(properties)
.filter(([k]) => k !== "ease")
.map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (effectiveEase) entries.push(`ease: ${JSON.stringify(effectiveEase)}`);
return parseExpr(`{ ${entries.join(", ")} }`);
}
@@ -0,0 +1,124 @@
// @vitest-environment happy-dom
import React, { act, useRef } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAppHotkeys } from "../../hooks/useAppHotkeys";
import { usePlayerStore } from "../../player/store/playerStore";
import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
import type { DomEditSelection } from "./domEditing";
import { SnapToolbar } from "./SnapToolbar";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
window.localStorage.clear();
usePlayerStore.getState().reset();
});
function renderToolbar(onSnapChange = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<SnapToolbar onSnapChange={onSnapChange} />);
});
return { root, onSnapChange };
}
function AppHotkeyHarness() {
const domEditSelectionRef = useRef<DomEditSelection | null>(null);
const clearDomSelectionRef = useRef<() => void>(() => undefined);
const domEditSaveTimestampRef = useRef(0);
const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
useAppHotkeys({
toggleTimelineVisibility: vi.fn(),
handleTimelineElementDelete: vi.fn(),
handleTimelineElementSplit: vi.fn(),
handleDomEditElementDelete: vi.fn(),
domEditSelectionRef,
clearDomSelectionRef,
editHistory: {
undo: vi.fn(async () => ({ ok: false })),
redo: vi.fn(async () => ({ ok: false })),
state: { undo: [], redo: [] },
},
readOptionalProjectFile: vi.fn(async () => ""),
readProjectFile: vi.fn(async () => ""),
writeProjectFile: vi.fn(async () => undefined),
domEditSaveTimestampRef,
showToast: vi.fn(),
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
waitForPendingDomEditSaves: vi.fn(async () => undefined),
leftSidebarRef,
handleCopy: vi.fn(() => false),
handlePaste: vi.fn(async () => undefined),
handleCut: vi.fn(async () => false),
onResetKeyframes: vi.fn(() => false),
onDeleteSelectedKeyframes: vi.fn(),
});
return null;
}
function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<>
<AppHotkeyHarness />
<SnapToolbar onSnapChange={onSnapChange} />
</>,
);
});
return { root, onSnapChange };
}
describe("SnapToolbar keyboard shortcuts", () => {
it("toggles snap on an unclaimed S keypress", () => {
const { root, onSnapChange } = renderToolbar();
act(() => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "s", bubbles: true, cancelable: true }),
);
});
expect(onSnapChange).toHaveBeenCalledWith(expect.objectContaining({ snapEnabled: false }));
act(() => root.unmount());
});
it("does not toggle snap when another handler already prevented S", () => {
const { root, onSnapChange } = renderToolbar();
const event = new KeyboardEvent("keydown", {
key: "s",
bubbles: true,
cancelable: true,
});
event.preventDefault();
act(() => {
document.dispatchEvent(event);
});
expect(onSnapChange).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("does not toggle snap when the app split shortcut claims S without a selected clip", () => {
const { root, onSnapChange } = renderToolbarWithAppHotkeys();
act(() => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "s", bubbles: true, cancelable: true }),
);
});
expect(onSnapChange).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
@@ -65,6 +65,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
useEffect(() => {
// fallow-ignore-next-line complexity
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const t = e.target;
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
if (t instanceof HTMLElement && t.isContentEditable) return;
+3 -2
View File
@@ -228,6 +228,9 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
}
if (event.key === "s" && !event.altKey) {
// Reserve bare `s` for Split even when the current selection cannot split,
// so secondary listeners do not reinterpret the same key as Snap toggle.
event.preventDefault();
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
if (selectedElementId) {
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
@@ -237,7 +240,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
currentTime > el.start &&
currentTime < el.start + el.duration
) {
event.preventDefault();
void cb.handleTimelineElementSplit(el, currentTime);
return;
}
@@ -245,7 +247,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
// that isn't in the raw `elements` list, so the s-key can't resolve them.
// Nudge toward the razor tool instead of failing silently.
if (!el && selectedElementId.includes("#")) {
event.preventDefault();
cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
return;
}
@@ -1,5 +1,18 @@
import { describe, expect, it } from "vitest";
// @vitest-environment happy-dom
import React, { act, isValidElement, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { AudioWaveform } from "../player/components/AudioWaveform";
import type { TimelineElement } from "../player/store/playerStore";
import { normalizeCompositionSrc } from "./useRenderClipContent";
import { useRenderClipContent } from "./useRenderClipContent";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
describe("normalizeCompositionSrc", () => {
const origin = "http://localhost:5190";
@@ -48,3 +61,43 @@ describe("normalizeCompositionSrc", () => {
expect(result).toBe("compositions/scenes/hero.html");
});
});
describe("useRenderClipContent", () => {
function renderClipContent(el: TimelineElement): ReactNode {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
let content: ReactNode = null;
function Harness() {
const render = useRenderClipContent({
projectIdRef: { current: "my-project" },
compIdToSrc: new Map(),
activePreviewUrl: "/api/projects/my-project/preview",
effectiveTimelineDuration: 12,
});
content = render(el, { clip: "#222", label: "#fff" });
return null;
}
act(() => {
root.render(React.createElement(Harness));
});
act(() => root.unmount());
return content;
}
it("renders audio clips as waveforms even when a composition preview URL is active", () => {
const content = renderClipContent({
id: "voiceover",
tag: "audio",
start: 1,
duration: 4,
track: 1,
src: "assets/voiceover.mp3",
});
expect(isValidElement(content)).toBe(true);
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
});
});
@@ -110,6 +110,13 @@ export function useRenderClipContent({
});
}
// Audio clips — waveform visualization. Resolve these before the generic
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
// captured frame from the currently drilled composition preview.
if (el.tag === "audio") {
return renderAudioClip(el, pid, style.label);
}
// When drilled into a composition, render all inner elements via
// CompositionThumbnail at their start time — most accurate visual.
if (activePreviewUrl && el.duration > 0) {
@@ -131,11 +138,6 @@ export function useRenderClipContent({
el.duration < effectiveTimelineDuration * 0.92 &&
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
// Audio clips — waveform visualization
if (el.tag === "audio") {
return renderAudioClip(el, pid, style.label);
}
if ((el.tag === "video" || el.tag === "img") && el.src) {
const mediaSrc = el.src.startsWith("http")
? el.src
@@ -3,7 +3,7 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach } from "vitest";
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import {
Timeline,
formatTimelineTickLabel,
@@ -54,6 +54,56 @@ describe("Timeline provider boundary", () => {
act(() => root.unmount());
});
it("opens the keyframe context menu without seeking to that keyframe", () => {
const host = document.createElement("div");
document.body.append(host);
Object.defineProperty(host, "clientWidth", {
configurable: true,
value: 720,
});
usePlayerStore.setState({
duration: 4,
timelineReady: true,
currentTime: 0.25,
selectedElementId: "clip-1",
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 4, track: 0 }],
keyframeCache: new Map([
[
"clip-1",
{
format: "percentage",
keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
},
],
]),
});
const onSeek = vi.fn();
const root = createRoot(host);
act(() => {
root.render(React.createElement(Timeline, { onSeek }));
});
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
expect(diamond).not.toBeNull();
act(() => {
diamond!.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
button: 2,
clientX: 120,
clientY: 40,
}),
);
});
expect(onSeek).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
describe("generateTicks", () => {
@@ -487,8 +487,6 @@ export const Timeline = memo(function Timeline({
if (el) {
setSelectedElementId(elId);
onSelectElement?.(el);
const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime);
}
const kfData = keyframeCache.get(elId);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function pointerEvent(type: string, init: PointerEventInit): Event {
if (typeof PointerEvent === "function") return new PointerEvent(type, init);
return new MouseEvent(type, init);
}
function renderDiamonds(onClickKeyframe = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<TimelineClipDiamonds
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
/>,
);
});
return { host, root, onClickKeyframe };
}
describe("TimelineClipDiamonds", () => {
it("treats primary pointerup without drag as a keyframe click", () => {
const { host, root, onClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
expect(diamond).not.toBeNull();
act(() => {
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
});
expect(onClickKeyframe).toHaveBeenCalledWith(50);
act(() => root.unmount());
});
it("does not treat secondary pointerup as a keyframe click", () => {
const { host, root, onClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
expect(diamond).not.toBeNull();
act(() => {
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 2 }));
});
expect(onClickKeyframe).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
@@ -178,6 +178,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const d = dragRef.current;
// No drag armed (canDrag false / non-primary press) → treat as a click.
if (!d || d.kfKey !== kfKey) {
if (e.button !== 0) return;
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
else onClickKeyframe?.(kf.percentage);
return;