mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): fix array-form keyframe writes, diamond click-deselect, and nested video sync
- fs.watch's async 'error' event had no listener, crashing the preview
server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
required object-form keyframes: {"0%": {...}}, silently no-opping on
array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
to the ancestor clip's onClick, which toggles selection off when the
clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
duration:0 + immediateRender static hold (what remove-all-keyframes
produces) as non-animated, so it kept showing a phantom diamond after
Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
data-start discarded the host composition's inherited start offset,
so a video nested inside a sub-composition played from the root
timeline's time instead of holding until its parent scene began
Fixes #1838
This commit is contained in:
@@ -2338,7 +2338,11 @@ export function moveKeyframeInScript(
|
||||
): string {
|
||||
const loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
// Array-form keyframes can't host an arbitrary destination percentage —
|
||||
// normalize to object form in place first (mirrors addKeyframeToScript).
|
||||
const kfNode =
|
||||
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
||||
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const match = findKeyframePropByPct(kfNode, fromPercentage);
|
||||
@@ -2395,7 +2399,11 @@ export function resizeKeyframedTweenInScript(
|
||||
): string {
|
||||
const loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
// Array-form keyframes can't host an arbitrary re-keyed percentage —
|
||||
// normalize to object form in place first (mirrors addKeyframeToScript).
|
||||
const kfNode =
|
||||
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
||||
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const seen = new Set<AstNode>();
|
||||
@@ -2595,7 +2603,13 @@ export function convertToKeyframesInScript(
|
||||
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
|
||||
let loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
// Array-form keyframes have no percentage-keyed props for
|
||||
// filterPercentageProps to read — normalize to object form first (mirrors
|
||||
// addKeyframeToScript/moveKeyframeInScript), otherwise this silently no-ops
|
||||
// on every array-form tween.
|
||||
const kfNode =
|
||||
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
||||
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const kfEntries = filterPercentageProps(kfNode)
|
||||
|
||||
@@ -1216,6 +1216,34 @@ describe("parity: moveKeyframeInScript (recast vs acorn)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: array-form `keyframes: [...]` has no explicit percentages, so
|
||||
// locateWithKeyframes/findKeyframesObjectNode (which only match the object
|
||||
// form) resolved to nothing and the move silently no-op'd — Studio's "Move to
|
||||
// Playhead" and drag-to-retime did nothing on any array-authored tween. Both
|
||||
// writers now normalize array → object form first (mirrors addKeyframeToScript).
|
||||
describe("moveKeyframeInScript: array-form keyframes (recast + acorn parity)", () => {
|
||||
for (const [label, move] of [
|
||||
["acorn", moveKeyframeAcorn],
|
||||
["recast", moveKeyframeRecast],
|
||||
] as const) {
|
||||
it(`${label}: normalizes the array then retimes the moved keyframe`, () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
const out = move(KF_ADD_ARRAY_SCRIPT, id, 50, 75);
|
||||
expect(out).not.toBe(KF_ADD_ARRAY_SCRIPT);
|
||||
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
|
||||
expect(kfs.map((k) => k.percentage)).toEqual([0, 75, 100]);
|
||||
expect(kfs.find((k) => k.percentage === 75)!.properties).toEqual({ x: 50, y: 80 });
|
||||
});
|
||||
}
|
||||
|
||||
it("parity: both writers reparse to the same model", () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
expect(modelOf(moveKeyframeAcorn(KF_ADD_ARRAY_SCRIPT, id, 50, 75))).toEqual(
|
||||
modelOf(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 75)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resizeKeyframedTweenInScript (boundary drag: re-key + grow window) ────────
|
||||
// Boundary drag-to-retime grows/shifts the tween window and RE-KEYS keyframes in
|
||||
// place. Unlike replace-with-keyframes (array rebuild), it must preserve author
|
||||
@@ -1272,6 +1300,67 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: same array-form gap as moveKeyframeInScript above — boundary
|
||||
// drag-to-retime re-keys existing keyframes to arbitrary percentages, which an
|
||||
// array can't host. Both writers now normalize array → object form first.
|
||||
const RESIZE_ARRAY_REMAP = [
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 25 },
|
||||
{ from: 100, to: 100 },
|
||||
];
|
||||
|
||||
describe("resizeKeyframedTweenInScript: array-form keyframes (recast + acorn parity)", () => {
|
||||
for (const [label, resize] of [
|
||||
["acorn", resizeKeyframedTweenAcorn],
|
||||
["recast", resizeKeyframedTweenRecast],
|
||||
] as const) {
|
||||
it(`${label}: normalizes the array then re-keys to the remapped percentages`, () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
const out = resize(KF_ADD_ARRAY_SCRIPT, id, 0.2, 2, RESIZE_ARRAY_REMAP);
|
||||
expect(out).not.toBe(KF_ADD_ARRAY_SCRIPT);
|
||||
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
|
||||
expect(kfs.map((k) => k.percentage)).toEqual([0, 25, 100]);
|
||||
expect(kfs.find((k) => k.percentage === 25)!.properties).toEqual({ x: 50, y: 80 });
|
||||
});
|
||||
}
|
||||
|
||||
it("parity: both writers reparse to the same model", () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
expect(
|
||||
modelOf(resizeKeyframedTweenAcorn(KF_ADD_ARRAY_SCRIPT, id, 0.2, 2, RESIZE_ARRAY_REMAP)),
|
||||
).toEqual(
|
||||
modelOf(resizeKeyframedTweenRecast(KF_ADD_ARRAY_SCRIPT, id, 0.2, 2, RESIZE_ARRAY_REMAP)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllKeyframesFromScript: array-form keyframes (recast + acorn parity)", () => {
|
||||
// Regression: the recast writer required an object-form `keyframes` node
|
||||
// before doing anything, so array-form tweens silently no-op'd — the studio
|
||||
// clears its keyframe cache optimistically on delete-all, so the diamonds
|
||||
// vanished from the UI while the script kept every keyframe untouched.
|
||||
for (const [label, removeAll] of [
|
||||
["acorn", removeAllAcorn],
|
||||
["recast", removeAllRecast],
|
||||
] as const) {
|
||||
it(`${label}: normalizes the array then collapses to the last keyframe`, () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
const out = removeAll(KF_ADD_ARRAY_SCRIPT, id);
|
||||
expect(out).not.toBe(KF_ADD_ARRAY_SCRIPT);
|
||||
const shape = shapeOf(out);
|
||||
expect(shape.keyframes).toBeUndefined();
|
||||
expect(shape.properties).toEqual({ x: 100, y: 0 });
|
||||
});
|
||||
}
|
||||
|
||||
it("parity: both writers reparse to the same model", () => {
|
||||
const id = acornId(KF_ADD_ARRAY_SCRIPT);
|
||||
expect(modelOf(removeAllAcorn(KF_ADD_ARRAY_SCRIPT, id))).toEqual(
|
||||
modelOf(removeAllRecast(KF_ADD_ARRAY_SCRIPT, id)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addAnimationWithKeyframesToScript parity (recast vs acorn) ───────────────
|
||||
// WS-3.C add path: both writers insert a new keyframed tl.to() call. The
|
||||
// inserted statement's authored model (selector, keyframes, duration, ease,
|
||||
|
||||
@@ -1209,17 +1209,20 @@ export function moveKeyframeInScript(
|
||||
fromPercentage: number,
|
||||
toPercentage: number,
|
||||
): string {
|
||||
const located = locateWithKeyframes(script, animationId);
|
||||
// ensureKeyframesNode (not locateWithKeyframes) so array-form `keyframes: [...]`
|
||||
// tweens normalize to percentage-object form first — locateWithKeyframes alone
|
||||
// bails on ArrayExpression, silently no-op'ing every move on an array-form tween.
|
||||
const located = ensureKeyframesNode(script, animationId);
|
||||
if (!located) return script;
|
||||
const { kfNode } = located;
|
||||
const { script: src, kfNode } = located;
|
||||
|
||||
const match = findKfPropByPct(kfNode, fromPercentage);
|
||||
if (!match) return script;
|
||||
if (!match) return src;
|
||||
// No-op ONLY for a negligible move (matches the drag's NOOP_EPSILON). The old
|
||||
// `collision.prop === match.prop` guard dropped EVERY sub-PCT_TOLERANCE (2%)
|
||||
// retime, because findKfPropByPct resolves the destination back onto the
|
||||
// from-keyframe — so a deliberate 1% drag committed nothing.
|
||||
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return script;
|
||||
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return src;
|
||||
// A destination keyframe is only a real collision (overwrite) when it's a
|
||||
// DIFFERENT keyframe; resolving back onto the from-keyframe is not.
|
||||
const dest = findKfPropByPct(kfNode, toPercentage);
|
||||
@@ -1234,15 +1237,15 @@ export function moveKeyframeInScript(
|
||||
if (collision && prop === collision.prop) continue;
|
||||
const pct = percentageFromKey(propKeyName(prop) ?? "");
|
||||
if (Number.isNaN(pct)) continue;
|
||||
entries.push({ pct, record: valueNodeToRecord(prop.value, script) });
|
||||
entries.push({ pct, record: valueNodeToRecord(prop.value, src) });
|
||||
}
|
||||
entries.push({ pct: toPercentage, record: valueNodeToRecord(match.prop.value, script) });
|
||||
entries.push({ pct: toPercentage, record: valueNodeToRecord(match.prop.value, src) });
|
||||
entries.sort((a, b) => a.pct - b.pct);
|
||||
|
||||
const body = entries
|
||||
.map((e) => `${JSON.stringify(`${e.pct}%`)}: ${recordToCode(e.record)}`)
|
||||
.join(", ");
|
||||
const ms = new MagicString(script);
|
||||
const ms = new MagicString(src);
|
||||
ms.overwrite(kfNode.start, kfNode.end, `{ ${body} }`);
|
||||
return ms.toString();
|
||||
}
|
||||
@@ -1266,9 +1269,11 @@ export function resizeKeyframedTweenInScript(
|
||||
newDuration: number,
|
||||
pctRemap: ReadonlyArray<{ from: number; to: number }>,
|
||||
): string {
|
||||
const located = locateWithKeyframes(script, animationId);
|
||||
// ensureKeyframesNode (not locateWithKeyframes) so array-form `keyframes: [...]`
|
||||
// tweens normalize to percentage-object form first — see moveKeyframeInScript.
|
||||
const located = ensureKeyframesNode(script, animationId);
|
||||
if (!located) return script;
|
||||
const { target, kfNode } = located;
|
||||
const { script: src, target, kfNode } = located;
|
||||
|
||||
// Resolve every re-key against the ORIGINAL AST first (offsets stay stable),
|
||||
// then splice — distinct key nodes, so the overwrites never overlap. A Set
|
||||
@@ -1282,7 +1287,7 @@ export function resizeKeyframedTweenInScript(
|
||||
edits.push({ keyNode: match.prop.key, to });
|
||||
}
|
||||
|
||||
const ms = new MagicString(script);
|
||||
const ms = new MagicString(src);
|
||||
for (const { keyNode, to } of edits) {
|
||||
ms.overwrite(keyNode.start, keyNode.end, JSON.stringify(`${to}%`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user