Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ pre-commit:
run: bunx oxlint --no-error-on-unmatched-pattern {staged_files}
format:
glob: "*.{js,jsx,ts,tsx,json,md,yaml,yml}"
# --no-error-on-unmatched-pattern: don't fail when staged files all
# fall under .prettierignore (e.g. docs-only changes to docs/docs.json).
run: bunx oxfmt --check --no-error-on-unmatched-pattern {staged_files}
# Auto-format and re-stage so the committed snapshot is always formatted.
# Replaces --check which only reports — that left unformatted files in
# commits when the hook ran after the amend snapshot was taken.
run: bunx oxfmt --no-error-on-unmatched-pattern {staged_files} && git add {staged_files}
typecheck:
glob: "*.{ts,tsx}"
run: cd packages/core && bunx tsc --noEmit && cd ../studio && bunx tsc --noEmit
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1792,6 +1792,19 @@ describe("keyframe mutations", () => {
expect(kf100.properties.y).toBe(50);
});

it("updateKeyframeInScript — ease-only update preserves existing properties", () => {
// Per-keyframe ease editing passes empty properties + an ease. The existing
// property bag must survive (don't wipe x/opacity when only the ease changes).
const id = getAnimId(KF_SCRIPT);
const updated = updateKeyframeInScript(KF_SCRIPT, id, 100, {}, "power2.inOut");
const kf100 = parseGsapScript(updated).animations[0].keyframes!.keyframes.find(
(k) => k.percentage === 100,
)!;
expect(kf100.ease).toBe("power2.inOut");
expect(kf100.properties.x).toBe(200);
expect(kf100.properties.opacity).toBe(1);
});

// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no percentages — GSAP
// distributes them evenly. The motion-path overlay drags/adds by percentage,
// which used to no-op on array-authored tweens (#puck-b / #shuttle).
Expand Down
39 changes: 34 additions & 5 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1243,13 +1243,17 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void {
}
}

function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
function applyUpdatesToCall(
call: TweenCallInfo,
updates: Partial<GsapAnimation> & { easeEach?: string },
): void {
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
reconcileEditableProperties(call.fromArg, updates.fromProperties);
}
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach);
else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
Expand Down Expand Up @@ -1282,10 +1286,13 @@ function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void {
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
const selector = JSON.stringify(anim.targetSelector);
const props: Record<string, number | string> = { ...anim.properties };
// `set` is instantaneous — GSAP ignores duration on it, so don't emit one.
if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
// immediateRender forces GSAP to apply the set when added to the timeline,
// not on the first seek — without it, tl.set at position 0 on a paused
// timeline is invisible until the playhead moves past 0.
if (anim.method === "set") entries.push("immediateRender: true");
if (anim.extras) {
for (const [k, v] of Object.entries(anim.extras)) {
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
Expand All @@ -1308,7 +1315,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation>,
updates: Partial<GsapAnimation> & { easeEach?: string },
): string {
let parsed: ParsedGsapAst;
try {
Expand Down Expand Up @@ -1437,6 +1444,7 @@ export function addAnimationWithKeyframesToScript(
auto?: boolean;
}>,
ease?: string,
easeEach?: string,
): { script: string; id: string } {
let parsed: ParsedGsapAst;
try {
Expand All @@ -1450,7 +1458,7 @@ export function addAnimationWithKeyframesToScript(
}

const selector = JSON.stringify(targetSelector);
const kfCode = buildKeyframeObjectCode(keyframes);
const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined);
const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`);
const posCode = valueToCode(position);
Expand Down Expand Up @@ -2216,6 +2224,27 @@ export function updateKeyframeInScript(
const match = findKeyframePropByPct(kfNode, percentage);
if (!match) return script;

if (Object.keys(properties).length === 0 && ease) {
// Ease-only update: preserve existing properties, just add/replace ease
const existing = match.prop.value;
if (existing?.type === "ObjectExpression") {
const props = (existing.properties ?? []) as AstNode[];
const easeIdx = props.findIndex(
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease",
);
const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
if (easeIdx >= 0) {
props[easeIdx] = easeNode;
} else {
props.push(easeNode);
}
return recast.print(loc.parsed.ast).code;
}
// Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there
// is no property bag to merge the ease into. Rebuilding from empty
// `properties` would wipe the primitive — leave the keyframe untouched.
return script;
}
match.prop.value = buildKeyframeValueNode(properties, ease);
return recast.print(loc.parsed.ast).code;
}
Expand Down
15 changes: 7 additions & 8 deletions packages/core/src/parsers/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation>,
updates: Partial<GsapAnimation> & { easeEach?: string },
): string {
if (!Object.keys(updates).length) return script;
const parsed = parseGsapScriptAcornForWrite(script);
Expand All @@ -324,13 +324,11 @@ export function updateAnimationInScript(
if (updates.duration !== undefined) {
upsertProp(ms, call.varsArg, "duration", updates.duration);
}
if (updates.ease !== undefined) {
// For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe),
// not a top-level ease. Writing top-level ease would leave the per-keyframe
// easing unchanged — the user's edit would silently do nothing.
const easeValue = updates.easeEach ?? updates.ease;
if (easeValue !== undefined) {
const kfNode = keyframesObjectNode(call.varsArg);
if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease);
else upsertProp(ms, call.varsArg, "ease", updates.ease);
if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue);
else upsertProp(ms, call.varsArg, "ease", easeValue);
}
if (updates.extras) {
for (const [key, value] of Object.entries(updates.extras)) {
Expand Down Expand Up @@ -1338,14 +1336,15 @@ export function addAnimationWithKeyframesToScript(
auto?: boolean;
}>,
ease?: string,
easeEach?: string,
): { script: string; id: string } {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, id: "" };
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) return { script, id: "" };

const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
const kfObjCode = buildKeyframeObjectCode(sorted);
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;
Expand Down
84 changes: 65 additions & 19 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,46 @@ export function initSandboxRuntimeModular(): void {
}

window.__timelines = window.__timelines || {};

// Resolve the root composition element with the same priority the rest of
// the runtime uses (explicit `data-root` marker first, then the topmost
// non-nested composition, then first in DOM order). Defined here so the
// array-normalization + data-start defaults below pick the same root the
// closure-based `resolveRootCompositionElement` does on multi-comp pages.
const findRootCompositionEl = (): HTMLElement | null => {
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
if (explicitRoot instanceof HTMLElement) return explicitRoot;
const nodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[];
return (
nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
nodes[0] ??
null
);
};

// Agents often write `window.__timelines = [tl]` (array) instead of the
// keyed-by-composition-id object the runtime expects. Normalize at init so
// the rest of the pipeline can assume a Record<string, timeline>.
if (Array.isArray(window.__timelines)) {
const arr = window.__timelines as unknown[];
const rootId = findRootCompositionEl()?.getAttribute("data-composition-id") ?? "root";
const normalized: Record<string, unknown> = {};
if (arr.length === 1) {
normalized[rootId] = arr[0];
} else {
for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i];
}
(window as Record<string, unknown>).__timelines = normalized;
}

// Agents sometimes omit data-start on the root composition element. The
// runtime skips timed-visibility for elements without it, making clips
// invisible and timelines non-seekable. Default to 0 for the root.
const rootComp = findRootCompositionEl();
if (rootComp && !rootComp.hasAttribute("data-start")) {
rootComp.setAttribute("data-start", "0");
}

const registerRuntimeCleanup = (callback: () => void) => {
runtimeCleanupCallbacks.push(callback);
};
Expand Down Expand Up @@ -218,23 +258,7 @@ export function initSandboxRuntimeModular(): void {
return `${parsed}px`;
};

const resolveRootCompositionElement = (): HTMLElement | null => {
// 1. Explicit root marker takes priority
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
if (explicitRoot instanceof HTMLElement) {
return explicitRoot;
}
// 3. Topmost composition element (not nested inside another)
const compositionNodes = Array.from(
document.querySelectorAll("[data-composition-id]"),
) as HTMLElement[];
if (compositionNodes.length === 0) return null;
return (
compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
compositionNodes[0] ??
null
);
};
const resolveRootCompositionElement = (): HTMLElement | null => findRootCompositionEl();

const applyCompositionSizing = () => {
const rootEl = resolveRootCompositionElement();
Expand Down Expand Up @@ -1003,16 +1027,38 @@ export function initSandboxRuntimeModular(): void {
state.capturedTimeline.timeScale(state.playbackRate);
}
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (boundDuration <= 0) {
// No resolvable duration (e.g. a set()-only timeline, or one whose
// duration isn't known yet). Kick GSAP off the creation position so the
// set() renders. For a finite-but-zero timeline progress(1) === progress(0);
// for an infinite-repeat timeline this lands on the first iteration's end
// frame, which is the best we can do without a known cycle length.
if (typeof state.capturedTimeline.progress === "function") {
state.capturedTimeline.progress(1, true);
state.capturedTimeline.progress(0, false);
state.capturedTimeline.pause();
}
}
if (boundDuration > 0) {
try {
clock.setDuration(boundDuration);
} catch {
// clock not yet initialized — duration will be set during TransportClock setup
}
state.capturedTimeline.pause();
const seekTime = Math.max(0, state.currentTime || 0);

if (typeof state.capturedTimeline.totalTime === "function") {
// GSAP won't render tl.set() at position 0 when the paused timeline
// starts there — play/pause/seek/totalTime are all no-ops at the
// creation position. Force the set to render by cycling progress past
// 0 (when the timeline implements it), then seek to the prior playhead
// (state.currentTime) so a rebind after a user scrub or soft-reload
// restore doesn't snap back to 0.
if (typeof state.capturedTimeline.progress === "function") {
state.capturedTimeline.progress(0.0001, true);
}
const seekTime = Math.max(0, state.currentTime || 0);
state.capturedTimeline.totalTime(seekTime, false);
state.capturedTimeline.pause();
}

// GSAP bakes the CSS `translate` into style.transform on seek.
Expand Down
7 changes: 6 additions & 1 deletion packages/studio/src/captions/hooks/useCaptionSync.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useRef } from "react";
import { useCaptionStore } from "../store";
import { useMountEffect } from "../../hooks/useMountEffect";
import { trackEvent } from "../../telemetry/client";
import type { CaptionStyle } from "../types";

interface CaptionOverrideEntry {
Expand Down Expand Up @@ -78,7 +79,11 @@ export function useCaptionSync(projectId: string | null) {
method: "PUT",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify(overrides, null, 2),
}).catch((err) => console.warn("[captions] auto-save failed:", err));
}).catch((error: unknown) => {
// Caption auto-save is a data-loss path; surface failures via telemetry
// so a silently-dropped edit isn't invisible (no console in studio).
trackEvent("studio_caption_autosave_failed", { error: String(error) });
});
}, []);

// Auto-save on model changes with 800ms debounce
Expand Down
12 changes: 4 additions & 8 deletions packages/studio/src/components/editor/BlockParamsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface BlockParamsPanelProps {
export const BlockParamsPanel = memo(function BlockParamsPanel({
blockTitle,
params,
compositionPath,
compositionPath: _compositionPath,
onClose,
}: BlockParamsPanelProps) {
const [values, setValues] = useState<Record<string, string>>(() => {
Expand All @@ -23,13 +23,9 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
return initial;
});

const handleChange = useCallback(
(key: string, value: string) => {
setValues((prev) => ({ ...prev, [key]: value }));
console.log(`[BlockParams] ${compositionPath} ${key}: ${value}`);
},
[compositionPath],
);
const handleChange = useCallback((key: string, value: string) => {
setValues((prev) => ({ ...prev, [key]: value }));
}, []);

return (
<div className="flex flex-col h-full">
Expand Down
8 changes: 8 additions & 0 deletions packages/studio/src/components/editor/manualOffsetDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,14 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
member.element.removeAttribute("data-hf-drag-initial-offset-y");
member.element.removeAttribute("data-hf-drag-gsap-base-x");
member.element.removeAttribute("data-hf-drag-gsap-base-y");
// Clear the draft's `translate: none` so the soft reload starts clean —
// otherwise button-less pointermoves after the reload compute deltas
// from a stale base and fling the element off-screen (#1673).
// Do NOT clearProps:"transform" — that nukes the committed GSAP position
// and causes a visual snap-back before the soft reload re-applies it.
if (member.element.style.getPropertyValue("translate") === "none") {
member.element.style.removeProperty("translate");
}
resumeGsapTimelines(member.element);
}
}
Expand Down
5 changes: 0 additions & 5 deletions packages/studio/src/components/editor/snapTargetCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,6 @@ export function collectSnapContext(input: {

const MAX_SNAP_TARGETS = 80;
const elements = collectVisibleElements(root, input.excludeElements, MAX_SNAP_TARGETS);
if (elements.length >= MAX_SNAP_TARGETS) {
console.warn(
`[snap] Target cap reached (${MAX_SNAP_TARGETS}). Elements beyond this limit are excluded from snap alignment.`,
);
}

const entries: Array<{
rect: { left: number; top: number; width: number; height: number };
Expand Down
1 change: 0 additions & 1 deletion packages/studio/src/components/panels/SlideshowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export function safeParseManifest(html: string): SlideshowManifest {
try {
return parseSlideshowManifest(html) ?? { slides: [] };
} catch {
console.warn("[SlideshowPanel] Failed to parse slideshow manifest; using empty manifest");
return { slides: [] };
}
}
Expand Down
3 changes: 1 addition & 2 deletions packages/studio/src/hooks/gsapDragPositionCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,7 @@ async function commitFlatViaKeyframes(
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
}
mainTl.seek(ct);
} catch (err) {
console.warn("[gsap-drag] start-value read failed; using identity from values", err);
} catch {
for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
} finally {
if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
Expand Down
8 changes: 6 additions & 2 deletions packages/studio/src/hooks/gsapRuntimeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ export async function tryGsapDragIntercept(
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
// place (idempotent), else add a new one. This also covers the stale-cache
// phantom — committing a set is correct because the element genuinely has no live motion.
if (!hasNonHoldTweenForElement(iframe, selector)) {
const hasNonHold = hasNonHoldTweenForElement(iframe, selector);

if (!hasNonHold) {
const existingSet =
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
? posAnim
Expand All @@ -251,7 +253,9 @@ export async function tryGsapDragIntercept(
return true;
}

if (!posAnim) return false;
if (!posAnim) {
return false;
}

// Verify the anim ID is still valid in the current file. The React-state
// `animations` list can lag behind the file after a prior mutation changed
Expand Down
Loading
Loading